Initial commit: SportsTime trip planning app

- Three-scenario planning engine (A: date range, B: selected games, C: directional routes)
- GeographicRouteExplorer with anchor game support for route exploration
- Shared ItineraryBuilder for travel segment calculation
- TravelEstimator for driving time/distance estimation
- SwiftUI views for trip creation and detail display
- CloudKit integration for schedule data
- Python scraping scripts for sports schedules

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-07 00:46:40 -06:00
commit 9088b46563
84 changed files with 180371 additions and 0 deletions

View File

@@ -0,0 +1,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
View 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
View 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
View 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
View 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
View 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
View 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()

View 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

File diff suppressed because it is too large Load Diff

76457
Scripts/data/games.json Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

93
Scripts/data/stadiums.csv Normal file
View 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,
1 id name city state latitude longitude capacity sport team_abbrevs source year_opened
2 manual_nba_atl State Farm Arena Atlanta 33.7573 -84.3963 0 NBA ['ATL'] manual
3 manual_nba_bos TD Garden Boston 42.3662 -71.0621 0 NBA ['BOS'] manual
4 manual_nba_brk Barclays Center Brooklyn 40.6826 -73.9754 0 NBA ['BRK'] manual
5 manual_nba_cho Spectrum Center Charlotte 35.2251 -80.8392 0 NBA ['CHO'] manual
6 manual_nba_chi United Center Chicago 41.8807 -87.6742 0 NBA ['CHI'] manual
7 manual_nba_cle Rocket Mortgage FieldHouse Cleveland 41.4965 -81.6882 0 NBA ['CLE'] manual
8 manual_nba_dal American Airlines Center Dallas 32.7905 -96.8103 0 NBA ['DAL'] manual
9 manual_nba_den Ball Arena Denver 39.7487 -105.0077 0 NBA ['DEN'] manual
10 manual_nba_det Little Caesars Arena Detroit 42.3411 -83.0553 0 NBA ['DET'] manual
11 manual_nba_gsw Chase Center San Francisco 37.768 -122.3879 0 NBA ['GSW'] manual
12 manual_nba_hou Toyota Center Houston 29.7508 -95.3621 0 NBA ['HOU'] manual
13 manual_nba_ind Gainbridge Fieldhouse Indianapolis 39.764 -86.1555 0 NBA ['IND'] manual
14 manual_nba_lac Intuit Dome Inglewood 33.9425 -118.3419 0 NBA ['LAC'] manual
15 manual_nba_lal Crypto.com Arena Los Angeles 34.043 -118.2673 0 NBA ['LAL'] manual
16 manual_nba_mem FedExForum Memphis 35.1382 -90.0506 0 NBA ['MEM'] manual
17 manual_nba_mia Kaseya Center Miami 25.7814 -80.187 0 NBA ['MIA'] manual
18 manual_nba_mil Fiserv Forum Milwaukee 43.0451 -87.9174 0 NBA ['MIL'] manual
19 manual_nba_min Target Center Minneapolis 44.9795 -93.2761 0 NBA ['MIN'] manual
20 manual_nba_nop Smoothie King Center New Orleans 29.949 -90.0821 0 NBA ['NOP'] manual
21 manual_nba_nyk Madison Square Garden New York 40.7505 -73.9934 0 NBA ['NYK'] manual
22 manual_nba_okc Paycom Center Oklahoma City 35.4634 -97.5151 0 NBA ['OKC'] manual
23 manual_nba_orl Kia Center Orlando 28.5392 -81.3839 0 NBA ['ORL'] manual
24 manual_nba_phi Wells Fargo Center Philadelphia 39.9012 -75.172 0 NBA ['PHI'] manual
25 manual_nba_pho Footprint Center Phoenix 33.4457 -112.0712 0 NBA ['PHO'] manual
26 manual_nba_por Moda Center Portland 45.5316 -122.6668 0 NBA ['POR'] manual
27 manual_nba_sac Golden 1 Center Sacramento 38.5802 -121.4997 0 NBA ['SAC'] manual
28 manual_nba_sas Frost Bank Center San Antonio 29.427 -98.4375 0 NBA ['SAS'] manual
29 manual_nba_tor Scotiabank Arena Toronto 43.6435 -79.3791 0 NBA ['TOR'] manual
30 manual_nba_uta Delta Center Salt Lake City 40.7683 -111.9011 0 NBA ['UTA'] manual
31 manual_nba_was Capital One Arena Washington 38.8982 -77.0209 0 NBA ['WAS'] manual
32 manual_mlb_ari Chase Field Phoenix AZ 33.4453 -112.0667 48686 MLB ['ARI'] manual
33 manual_mlb_atl Truist Park Atlanta GA 33.8907 -84.4678 41084 MLB ['ATL'] manual
34 manual_mlb_bal Oriole Park at Camden Yards Baltimore MD 39.2838 -76.6218 45971 MLB ['BAL'] manual
35 manual_mlb_bos Fenway Park Boston MA 42.3467 -71.0972 37755 MLB ['BOS'] manual
36 manual_mlb_chc Wrigley Field Chicago IL 41.9484 -87.6553 41649 MLB ['CHC'] manual
37 manual_mlb_chw Guaranteed Rate Field Chicago IL 41.8299 -87.6338 40615 MLB ['CHW'] manual
38 manual_mlb_cin Great American Ball Park Cincinnati OH 39.0979 -84.5082 42319 MLB ['CIN'] manual
39 manual_mlb_cle Progressive Field Cleveland OH 41.4962 -81.6852 34830 MLB ['CLE'] manual
40 manual_mlb_col Coors Field Denver CO 39.7559 -104.9942 50144 MLB ['COL'] manual
41 manual_mlb_det Comerica Park Detroit MI 42.339 -83.0485 41083 MLB ['DET'] manual
42 manual_mlb_hou Minute Maid Park Houston TX 29.7573 -95.3555 41168 MLB ['HOU'] manual
43 manual_mlb_kcr Kauffman Stadium Kansas City MO 39.0517 -94.4803 37903 MLB ['KCR'] manual
44 manual_mlb_laa Angel Stadium Anaheim CA 33.8003 -117.8827 45517 MLB ['LAA'] manual
45 manual_mlb_lad Dodger Stadium Los Angeles CA 34.0739 -118.24 56000 MLB ['LAD'] manual
46 manual_mlb_mia LoanDepot Park Miami FL 25.7781 -80.2196 36742 MLB ['MIA'] manual
47 manual_mlb_mil American Family Field Milwaukee WI 43.028 -87.9712 41900 MLB ['MIL'] manual
48 manual_mlb_min Target Field Minneapolis MN 44.9817 -93.2776 38544 MLB ['MIN'] manual
49 manual_mlb_nym Citi Field New York NY 40.7571 -73.8458 41922 MLB ['NYM'] manual
50 manual_mlb_nyy Yankee Stadium New York NY 40.8296 -73.9262 46537 MLB ['NYY'] manual
51 manual_mlb_oak Sutter Health Park Sacramento CA 38.5802 -121.5097 14014 MLB ['OAK'] manual
52 manual_mlb_phi Citizens Bank Park Philadelphia PA 39.9061 -75.1665 42792 MLB ['PHI'] manual
53 manual_mlb_pit PNC Park Pittsburgh PA 40.4469 -80.0057 38362 MLB ['PIT'] manual
54 manual_mlb_sdp Petco Park San Diego CA 32.7076 -117.157 40209 MLB ['SDP'] manual
55 manual_mlb_sfg Oracle Park San Francisco CA 37.7786 -122.3893 41265 MLB ['SFG'] manual
56 manual_mlb_sea T-Mobile Park Seattle WA 47.5914 -122.3325 47929 MLB ['SEA'] manual
57 manual_mlb_stl Busch Stadium St. Louis MO 38.6226 -90.1928 45494 MLB ['STL'] manual
58 manual_mlb_tbr Tropicana Field St. Petersburg FL 27.7682 -82.6534 25000 MLB ['TBR'] manual
59 manual_mlb_tex Globe Life Field Arlington TX 32.7473 -97.0845 40300 MLB ['TEX'] manual
60 manual_mlb_tor Rogers Centre Toronto ON 43.6414 -79.3894 49282 MLB ['TOR'] manual
61 manual_mlb_wsn Nationals Park Washington DC 38.873 -77.0074 41339 MLB ['WSN'] manual
62 manual_nhl_ana Honda Center Anaheim CA 33.8078 -117.8765 17174 NHL ['ANA'] manual
63 manual_nhl_ari Delta Center Salt Lake City UT 40.7683 -111.9011 18306 NHL ['ARI'] manual
64 manual_nhl_bos TD Garden Boston MA 42.3662 -71.0621 17565 NHL ['BOS'] manual
65 manual_nhl_buf KeyBank Center Buffalo NY 42.875 -78.8764 19070 NHL ['BUF'] manual
66 manual_nhl_cgy Scotiabank Saddledome Calgary AB 51.0374 -114.0519 19289 NHL ['CGY'] manual
67 manual_nhl_car PNC Arena Raleigh NC 35.8034 -78.722 18680 NHL ['CAR'] manual
68 manual_nhl_chi United Center Chicago IL 41.8807 -87.6742 19717 NHL ['CHI'] manual
69 manual_nhl_col Ball Arena Denver CO 39.7487 -105.0077 18007 NHL ['COL'] manual
70 manual_nhl_cbj Nationwide Arena Columbus OH 39.9693 -83.0061 18500 NHL ['CBJ'] manual
71 manual_nhl_dal American Airlines Center Dallas TX 32.7905 -96.8103 18532 NHL ['DAL'] manual
72 manual_nhl_det Little Caesars Arena Detroit MI 42.3411 -83.0553 19515 NHL ['DET'] manual
73 manual_nhl_edm Rogers Place Edmonton AB 53.5469 -113.4978 18347 NHL ['EDM'] manual
74 manual_nhl_fla Amerant Bank Arena Sunrise FL 26.1584 -80.3256 19250 NHL ['FLA'] manual
75 manual_nhl_lak Crypto.com Arena Los Angeles CA 34.043 -118.2673 18230 NHL ['LAK'] manual
76 manual_nhl_min Xcel Energy Center St. Paul MN 44.9448 -93.101 17954 NHL ['MIN'] manual
77 manual_nhl_mtl Bell Centre Montreal QC 45.4961 -73.5693 21302 NHL ['MTL'] manual
78 manual_nhl_nsh Bridgestone Arena Nashville TN 36.1592 -86.7785 17159 NHL ['NSH'] manual
79 manual_nhl_njd Prudential Center Newark NJ 40.7334 -74.1712 16514 NHL ['NJD'] manual
80 manual_nhl_nyi UBS Arena Elmont NY 40.7161 -73.7246 17255 NHL ['NYI'] manual
81 manual_nhl_nyr Madison Square Garden New York NY 40.7505 -73.9934 18006 NHL ['NYR'] manual
82 manual_nhl_ott Canadian Tire Centre Ottawa ON 45.2969 -75.9272 18652 NHL ['OTT'] manual
83 manual_nhl_phi Wells Fargo Center Philadelphia PA 39.9012 -75.172 19543 NHL ['PHI'] manual
84 manual_nhl_pit PPG Paints Arena Pittsburgh PA 40.4395 -79.9892 18387 NHL ['PIT'] manual
85 manual_nhl_sjs SAP Center San Jose CA 37.3327 -121.901 17562 NHL ['SJS'] manual
86 manual_nhl_sea Climate Pledge Arena Seattle WA 47.6221 -122.354 17100 NHL ['SEA'] manual
87 manual_nhl_stl Enterprise Center St. Louis MO 38.6268 -90.2025 18096 NHL ['STL'] manual
88 manual_nhl_tbl Amalie Arena Tampa FL 27.9426 -82.4519 19092 NHL ['TBL'] manual
89 manual_nhl_tor Scotiabank Arena Toronto ON 43.6435 -79.3791 18819 NHL ['TOR'] manual
90 manual_nhl_van Rogers Arena Vancouver BC 49.2778 -123.1089 18910 NHL ['VAN'] manual
91 manual_nhl_vgk T-Mobile Arena Las Vegas NV 36.1028 -115.1784 17500 NHL ['VGK'] manual
92 manual_nhl_wsh Capital One Arena Washington DC 38.8982 -77.0209 18573 NHL ['WSH'] manual
93 manual_nhl_wpg Canada Life Centre Winnipeg MB 49.8928 -97.1436 15321 NHL ['WPG'] manual

1382
Scripts/data/stadiums.json Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,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
View 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
View 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
View 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
View 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
View 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()

View 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 */;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

10
SportsTime/Info.plist Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,348 @@
//
// ScenarioBPlanner.swift
// SportsTime
//
// Scenario B: Selected games planning.
// User selects specific games they MUST see. Those are fixed anchors that cannot be removed.
//
// Key Features:
// - Selected games are "anchors" - they MUST appear in every valid route
// - Sliding window logic when only trip duration (no specific dates) is provided
// - Additional games from date range can be added if they fit geographically
//
// Sliding Window Algorithm:
// When user provides selected games + day span (e.g., 10 days) without specific dates:
// 1. Find first and last selected game dates
// 2. Generate all possible windows of the given duration that contain ALL selected games
// 3. Window 1: Last selected game is on last day
// 4. Window N: First selected game is on first day
// 5. Explore routes for each window, return best options
//
// Example:
// Selected games on Jan 5, Jan 8, Jan 12. Day span = 10 days.
// - Window 1: Jan 3-12 (Jan 12 is last day)
// - Window 2: Jan 4-13
// - Window 3: Jan 5-14 (Jan 5 is first day)
// For each window, find all games and explore routes with selected as anchors.
//
import Foundation
import CoreLocation
/// Scenario B: Selected games planning
/// Input: selected_games, date_range (or trip_duration), optional must_stop
/// Output: Itinerary options connecting all selected games with possible bonus games
final class ScenarioBPlanner: ScenarioPlanner {
// MARK: - ScenarioPlanner Protocol
func plan(request: PlanningRequest) -> ItineraryResult {
let selectedGames = request.selectedGames
//
// Step 1: Validate selected games exist
//
if selectedGames.isEmpty {
return .failure(
PlanningFailure(
reason: .noValidRoutes,
violations: [
ConstraintViolation(
type: .selectedGames,
description: "No games selected",
severity: .error
)
]
)
)
}
//
// Step 2: Generate date ranges (sliding window or single range)
//
let dateRanges = generateDateRanges(
selectedGames: selectedGames,
request: request
)
if dateRanges.isEmpty {
return .failure(
PlanningFailure(
reason: .missingDateRange,
violations: [
ConstraintViolation(
type: .dateRange,
description: "Cannot determine valid date range for selected games",
severity: .error
)
]
)
)
}
//
// Step 3: For each date range, find routes with anchors
//
let anchorGameIds = Set(selectedGames.map { $0.id })
var allItineraryOptions: [ItineraryOption] = []
for dateRange in dateRanges {
// Find all games in this date range
let gamesInRange = request.allGames
.filter { dateRange.contains($0.startTime) }
.sorted { $0.startTime < $1.startTime }
// Skip if no games (shouldn't happen if date range is valid)
guard !gamesInRange.isEmpty else { continue }
// Verify all selected games are in range
let selectedInRange = selectedGames.allSatisfy { game in
dateRange.contains(game.startTime)
}
guard selectedInRange else { continue }
// Find all sensible routes that include the anchor games
let validRoutes = GeographicRouteExplorer.findAllSensibleRoutes(
from: gamesInRange,
stadiums: request.stadiums,
anchorGameIds: anchorGameIds,
stopBuilder: buildStops
)
// Build itineraries for each valid route
for routeGames in validRoutes {
let stops = buildStops(from: routeGames, stadiums: request.stadiums)
guard !stops.isEmpty else { continue }
// Use shared ItineraryBuilder with arrival time validator
guard let itinerary = ItineraryBuilder.build(
stops: stops,
constraints: request.drivingConstraints,
logPrefix: "[ScenarioB]",
segmentValidator: ItineraryBuilder.arrivalBeforeGameStart()
) else {
continue
}
let selectedCount = routeGames.filter { anchorGameIds.contains($0.id) }.count
let bonusCount = routeGames.count - selectedCount
let cities = stops.map { $0.city }.joined(separator: "")
let option = ItineraryOption(
rank: 0, // Will re-rank later
stops: itinerary.stops,
travelSegments: itinerary.travelSegments,
totalDrivingHours: itinerary.totalDrivingHours,
totalDistanceMiles: itinerary.totalDistanceMiles,
geographicRationale: "\(selectedCount) selected + \(bonusCount) bonus games: \(cities)"
)
allItineraryOptions.append(option)
}
}
//
// Step 4: Return ranked results
//
if allItineraryOptions.isEmpty {
return .failure(
PlanningFailure(
reason: .constraintsUnsatisfiable,
violations: [
ConstraintViolation(
type: .geographicSanity,
description: "Cannot create a geographically sensible route connecting selected games",
severity: .error
)
]
)
)
}
// Sort by total games (most first), then by driving hours (less first)
let sorted = allItineraryOptions.sorted { a, b in
if a.stops.flatMap({ $0.games }).count != b.stops.flatMap({ $0.games }).count {
return a.stops.flatMap({ $0.games }).count > b.stops.flatMap({ $0.games }).count
}
return a.totalDrivingHours < b.totalDrivingHours
}
// Re-rank and limit
let rankedOptions = sorted.prefix(10).enumerated().map { index, option in
ItineraryOption(
rank: index + 1,
stops: option.stops,
travelSegments: option.travelSegments,
totalDrivingHours: option.totalDrivingHours,
totalDistanceMiles: option.totalDistanceMiles,
geographicRationale: option.geographicRationale
)
}
print("[ScenarioB] Returning \(rankedOptions.count) itinerary options")
return .success(Array(rankedOptions))
}
// MARK: - Date Range Generation (Sliding Window)
/// Generates all valid date ranges for the selected games.
///
/// Two modes:
/// 1. If explicit date range provided: Use it directly (validate selected games fit)
/// 2. If only trip duration provided: Generate sliding windows
///
/// Sliding Window Logic:
/// Selected games: Jan 5, Jan 8, Jan 12. Duration: 10 days.
/// - Window must contain all selected games
/// - First window: ends on last selected game date (Jan 3-12)
/// - Slide forward one day at a time
/// - Last window: starts on first selected game date (Jan 5-14)
///
private func generateDateRanges(
selectedGames: [Game],
request: PlanningRequest
) -> [DateInterval] {
// If explicit date range exists, use it
if let dateRange = request.dateRange {
return [dateRange]
}
// Otherwise, use trip duration to create sliding windows
let duration = request.preferences.effectiveTripDuration
guard duration > 0 else { return [] }
// Find the span of selected games
let sortedGames = selectedGames.sorted { $0.startTime < $1.startTime }
guard let firstGame = sortedGames.first,
let lastGame = sortedGames.last else {
return []
}
let firstGameDate = Calendar.current.startOfDay(for: firstGame.startTime)
let lastGameDate = Calendar.current.startOfDay(for: lastGame.startTime)
// Calculate how many days the selected games span
let gameSpanDays = Calendar.current.dateComponents(
[.day],
from: firstGameDate,
to: lastGameDate
).day ?? 0
// If selected games span more days than trip duration, can't fit
if gameSpanDays >= duration {
// Just return one window that exactly covers the games
let start = firstGameDate
let end = Calendar.current.date(
byAdding: .day,
value: gameSpanDays + 1,
to: start
) ?? lastGameDate
return [DateInterval(start: start, end: end)]
}
// Generate sliding windows
var dateRanges: [DateInterval] = []
// First window: last selected game is on last day of window
// Window end = lastGameDate + 1 day (to include the game)
// Window start = end - duration days
let firstWindowEnd = Calendar.current.date(
byAdding: .day,
value: 1,
to: lastGameDate
)!
let firstWindowStart = Calendar.current.date(
byAdding: .day,
value: -duration,
to: firstWindowEnd
)!
// Last window: first selected game is on first day of window
// Window start = firstGameDate
// Window end = start + duration days
let lastWindowStart = firstGameDate
let lastWindowEnd = Calendar.current.date(
byAdding: .day,
value: duration,
to: lastWindowStart
)!
// Slide from first window to last window
var currentStart = firstWindowStart
while currentStart <= lastWindowStart {
let windowEnd = Calendar.current.date(
byAdding: .day,
value: duration,
to: currentStart
)!
let window = DateInterval(start: currentStart, end: windowEnd)
dateRanges.append(window)
// Slide forward one day
currentStart = Calendar.current.date(
byAdding: .day,
value: 1,
to: currentStart
)!
}
print("[ScenarioB] Generated \(dateRanges.count) sliding windows for \(duration)-day trip")
return dateRanges
}
// MARK: - Stop Building
/// Converts a list of games into itinerary stops.
/// Groups games by stadium, creates one stop per unique stadium.
private func buildStops(
from games: [Game],
stadiums: [UUID: Stadium]
) -> [ItineraryStop] {
// Group games by stadium
var stadiumGames: [UUID: [Game]] = [:]
for game in games {
stadiumGames[game.stadiumId, default: []].append(game)
}
// Create stops in chronological order (first game at each stadium)
var stops: [ItineraryStop] = []
var processedStadiums: Set<UUID> = []
for game in games {
guard !processedStadiums.contains(game.stadiumId) else { continue }
processedStadiums.insert(game.stadiumId)
let gamesAtStadium = stadiumGames[game.stadiumId] ?? [game]
let sortedGames = gamesAtStadium.sorted { $0.startTime < $1.startTime }
let stadium = stadiums[game.stadiumId]
let city = stadium?.city ?? "Unknown"
let state = stadium?.state ?? ""
let coordinate = stadium?.coordinate
let location = LocationInput(
name: city,
coordinate: coordinate,
address: stadium?.fullAddress
)
let stop = ItineraryStop(
city: city,
state: state,
coordinate: coordinate,
games: sortedGames.map { $0.id },
arrivalDate: sortedGames.first?.gameDate ?? Date(),
departureDate: sortedGames.last?.gameDate ?? Date(),
location: location,
firstGameStart: sortedGames.first?.startTime
)
stops.append(stop)
}
return stops
}
}

View File

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

View File

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

View File

@@ -0,0 +1,396 @@
//
// ScheduleMatcher.swift
// SportsTime
//
import Foundation
import CoreLocation
/// Finds and scores candidate games for trip planning.
///
/// Updated for the new scenario-based planning:
/// - Scenario A (Date Range): Find games in date range, cluster by region
/// - Scenario B (Selected Games): Validate must-see games, find optional additions
/// - Scenario C (Start+End): Find games along directional corridor with progress check
struct ScheduleMatcher {
// MARK: - Find Candidate Games (Legacy + Scenario C Support)
/// Finds candidate games along a corridor between start and end.
/// Supports directional filtering for Scenario C.
///
/// - Parameters:
/// - request: Planning request with preferences and games
/// - startCoordinate: Starting location
/// - endCoordinate: Ending location
/// - enforceDirection: If true, only include games that make progress toward end
/// - Returns: Array of game candidates sorted by score
func findCandidateGames(
from request: PlanningRequest,
startCoordinate: CLLocationCoordinate2D,
endCoordinate: CLLocationCoordinate2D,
enforceDirection: Bool = false
) -> [GameCandidate] {
var candidates: [GameCandidate] = []
// Calculate the corridor between start and end
let corridor = RouteCorridorCalculator(
start: startCoordinate,
end: endCoordinate,
maxDetourFactor: detourFactorFor(request.preferences.leisureLevel)
)
for game in request.availableGames {
guard let stadium = request.stadiums[game.stadiumId],
let homeTeam = request.teams[game.homeTeamId],
let awayTeam = request.teams[game.awayTeamId] else {
continue
}
// Check if game is within date range
guard game.dateTime >= request.preferences.startDate,
game.dateTime <= request.preferences.endDate else {
continue
}
// Check sport filter
guard request.preferences.sports.contains(game.sport) else {
continue
}
// Calculate detour distance
let detourDistance = corridor.detourDistance(to: stadium.coordinate)
// For directional routes, check if this stadium makes progress
if enforceDirection {
let distanceToEnd = corridor.distanceToEnd(from: stadium.coordinate)
let startDistanceToEnd = corridor.directDistance
// Skip if stadium is behind the start (going backwards)
if distanceToEnd > startDistanceToEnd * 1.1 { // 10% tolerance
continue
}
}
// Skip if too far from route (unless must-see)
let isMustSee = request.preferences.mustSeeGameIds.contains(game.id)
if !isMustSee && detourDistance > corridor.maxDetourDistance {
continue
}
// Score the game
let score = scoreGame(
game: game,
homeTeam: homeTeam,
awayTeam: awayTeam,
detourDistance: detourDistance,
isMustSee: isMustSee,
request: request
)
let candidate = GameCandidate(
id: game.id,
game: game,
stadium: stadium,
homeTeam: homeTeam,
awayTeam: awayTeam,
detourDistance: detourDistance,
score: score
)
candidates.append(candidate)
}
// Sort by score (highest first)
return candidates.sorted { $0.score > $1.score }
}
// MARK: - Directional Game Filtering (Scenario C)
/// Finds games along a directional route from start to end.
/// Ensures monotonic progress toward destination.
///
/// - Parameters:
/// - request: Planning request
/// - startCoordinate: Starting location
/// - endCoordinate: Destination
/// - corridorWidthPercent: Width of corridor as percentage of direct distance
/// - Returns: Games sorted by their position along the route
func findDirectionalGames(
from request: PlanningRequest,
startCoordinate: CLLocationCoordinate2D,
endCoordinate: CLLocationCoordinate2D,
corridorWidthPercent: Double = 0.3
) -> [GameCandidate] {
let corridor = RouteCorridorCalculator(
start: startCoordinate,
end: endCoordinate,
maxDetourFactor: 1.0 + corridorWidthPercent
)
var candidates: [GameCandidate] = []
for game in request.availableGames {
guard let stadium = request.stadiums[game.stadiumId],
let homeTeam = request.teams[game.homeTeamId],
let awayTeam = request.teams[game.awayTeamId] else {
continue
}
// Date and sport filter
guard game.dateTime >= request.preferences.startDate,
game.dateTime <= request.preferences.endDate,
request.preferences.sports.contains(game.sport) else {
continue
}
// Calculate progress along route (0 = start, 1 = end)
let progress = corridor.progressAlongRoute(point: stadium.coordinate)
// Only include games that are along the route (positive progress, not behind start)
guard progress >= -0.1 && progress <= 1.1 else {
continue
}
// Check corridor width
let detourDistance = corridor.detourDistance(to: stadium.coordinate)
let isMustSee = request.preferences.mustSeeGameIds.contains(game.id)
if !isMustSee && detourDistance > corridor.maxDetourDistance {
continue
}
let score = scoreGame(
game: game,
homeTeam: homeTeam,
awayTeam: awayTeam,
detourDistance: detourDistance,
isMustSee: isMustSee,
request: request
)
let candidate = GameCandidate(
id: game.id,
game: game,
stadium: stadium,
homeTeam: homeTeam,
awayTeam: awayTeam,
detourDistance: detourDistance,
score: score
)
candidates.append(candidate)
}
// Sort by date (chronological order is the primary constraint)
return candidates.sorted { $0.game.dateTime < $1.game.dateTime }
}
// MARK: - Game Scoring
private func scoreGame(
game: Game,
homeTeam: Team,
awayTeam: Team,
detourDistance: Double,
isMustSee: Bool,
request: PlanningRequest
) -> Double {
var score: Double = 50.0 // Base score
// Must-see bonus
if isMustSee {
score += 100.0
}
// Playoff bonus
if game.isPlayoff {
score += 30.0
}
// Weekend bonus (more convenient)
let weekday = Calendar.current.component(.weekday, from: game.dateTime)
if weekday == 1 || weekday == 7 { // Sunday or Saturday
score += 10.0
}
// Evening game bonus (day games harder to schedule around)
let hour = Calendar.current.component(.hour, from: game.dateTime)
if hour >= 17 { // 5 PM or later
score += 5.0
}
// Detour penalty
let detourMiles = detourDistance * 0.000621371
score -= detourMiles * 0.1 // Lose 0.1 points per mile of detour
// Preferred city bonus
if request.preferences.preferredCities.contains(homeTeam.city) {
score += 15.0
}
// Must-stop location bonus
if request.preferences.mustStopLocations.contains(where: { $0.name.lowercased() == homeTeam.city.lowercased() }) {
score += 25.0
}
return max(0, score)
}
private func detourFactorFor(_ leisureLevel: LeisureLevel) -> Double {
switch leisureLevel {
case .packed: return 1.3 // 30% detour allowed
case .moderate: return 1.5 // 50% detour allowed
case .relaxed: return 2.0 // 100% detour allowed
}
}
// MARK: - Find Games at Location
func findGames(
at stadium: Stadium,
within dateRange: ClosedRange<Date>,
from games: [Game]
) -> [Game] {
games.filter { game in
game.stadiumId == stadium.id &&
dateRange.contains(game.dateTime)
}.sorted { $0.dateTime < $1.dateTime }
}
// MARK: - Find Other Sports
func findOtherSportsGames(
along route: [CLLocationCoordinate2D],
excludingSports: Set<Sport>,
within dateRange: ClosedRange<Date>,
games: [Game],
stadiums: [UUID: Stadium],
teams: [UUID: Team],
maxDetourMiles: Double = 50
) -> [GameCandidate] {
var candidates: [GameCandidate] = []
for game in games {
// Skip if sport is already selected
if excludingSports.contains(game.sport) { continue }
// Skip if outside date range
if !dateRange.contains(game.dateTime) { continue }
guard let stadium = stadiums[game.stadiumId],
let homeTeam = teams[game.homeTeamId],
let awayTeam = teams[game.awayTeamId] else {
continue
}
// Check if stadium is near the route
let minDistance = route.map { coord in
CLLocation(latitude: coord.latitude, longitude: coord.longitude)
.distance(from: stadium.location)
}.min() ?? .greatestFiniteMagnitude
let distanceMiles = minDistance * 0.000621371
if distanceMiles <= maxDetourMiles {
let candidate = GameCandidate(
id: game.id,
game: game,
stadium: stadium,
homeTeam: homeTeam,
awayTeam: awayTeam,
detourDistance: minDistance,
score: 50.0 - distanceMiles // Score inversely proportional to detour
)
candidates.append(candidate)
}
}
return candidates.sorted { $0.score > $1.score }
}
// MARK: - Validate Games for Scenarios
/// Validates that all must-see games are within the date range.
/// Used for Scenario B validation.
func validateMustSeeGamesInRange(
mustSeeGameIds: Set<UUID>,
allGames: [Game],
dateRange: ClosedRange<Date>
) -> (valid: Bool, outOfRange: [UUID]) {
var outOfRange: [UUID] = []
for gameId in mustSeeGameIds {
guard let game = allGames.first(where: { $0.id == gameId }) else {
continue
}
if !dateRange.contains(game.dateTime) {
outOfRange.append(gameId)
}
}
return (outOfRange.isEmpty, outOfRange)
}
}
// MARK: - Route Corridor Calculator
struct RouteCorridorCalculator {
let start: CLLocationCoordinate2D
let end: CLLocationCoordinate2D
let maxDetourFactor: Double
var directDistance: CLLocationDistance {
CLLocation(latitude: start.latitude, longitude: start.longitude)
.distance(from: CLLocation(latitude: end.latitude, longitude: end.longitude))
}
var maxDetourDistance: CLLocationDistance {
directDistance * (maxDetourFactor - 1.0)
}
func detourDistance(to point: CLLocationCoordinate2D) -> CLLocationDistance {
let startToPoint = CLLocation(latitude: start.latitude, longitude: start.longitude)
.distance(from: CLLocation(latitude: point.latitude, longitude: point.longitude))
let pointToEnd = CLLocation(latitude: point.latitude, longitude: point.longitude)
.distance(from: CLLocation(latitude: end.latitude, longitude: end.longitude))
let totalViaPoint = startToPoint + pointToEnd
return max(0, totalViaPoint - directDistance)
}
func isWithinCorridor(_ point: CLLocationCoordinate2D) -> Bool {
detourDistance(to: point) <= maxDetourDistance
}
/// Returns the distance from a point to the end location.
func distanceToEnd(from point: CLLocationCoordinate2D) -> CLLocationDistance {
CLLocation(latitude: point.latitude, longitude: point.longitude)
.distance(from: CLLocation(latitude: end.latitude, longitude: end.longitude))
}
/// Calculates progress along the route (0 = at start, 1 = at end).
/// Can be negative (behind start) or > 1 (past end).
func progressAlongRoute(point: CLLocationCoordinate2D) -> Double {
guard directDistance > 0 else { return 0 }
let distFromStart = CLLocation(latitude: start.latitude, longitude: start.longitude)
.distance(from: CLLocation(latitude: point.latitude, longitude: point.longitude))
let distFromEnd = CLLocation(latitude: point.latitude, longitude: point.longitude)
.distance(from: CLLocation(latitude: end.latitude, longitude: end.longitude))
// Use the law of cosines to project onto the line
// progress = (d_start² + d_total² - d_end²) / (2 * d_total²)
let dStart = distFromStart
let dEnd = distFromEnd
let dTotal = directDistance
let numerator = (dStart * dStart) + (dTotal * dTotal) - (dEnd * dEnd)
let denominator = 2 * dTotal * dTotal
return numerator / denominator
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

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

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

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

View 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 its 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()
}
}
}

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB