update claudemd file
This commit is contained in:
734
CLAUDE.md
734
CLAUDE.md
@@ -1,6 +1,6 @@
|
|||||||
# CLAUDE.md
|
# SportsTime
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
iOS app for planning multi-stop sports road trips. Offline-first architecture with SwiftData persistence, CloudKit sync, and a multi-scenario trip planning engine.
|
||||||
|
|
||||||
## Build & Run Commands
|
## Build & Run Commands
|
||||||
|
|
||||||
@@ -8,14 +8,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
# Build the iOS app
|
# Build the iOS app
|
||||||
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build
|
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build
|
||||||
|
|
||||||
# Run tests
|
# Run all tests
|
||||||
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' test
|
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' test
|
||||||
|
|
||||||
# Run specific test suite
|
# 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
|
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
|
# 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
|
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/TripPlanningEngineTests/planningMode_dateRange test
|
||||||
|
|
||||||
# Data scraping (Python)
|
# Data scraping (Python)
|
||||||
cd Scripts && pip install -r requirements.txt
|
cd Scripts && pip install -r requirements.txt
|
||||||
@@ -24,133 +24,499 @@ python scrape_schedules.py --sport all --season 2026
|
|||||||
|
|
||||||
## Architecture Overview
|
## Architecture Overview
|
||||||
|
|
||||||
This is an iOS app for planning multi-stop sports road trips. It uses **Clean MVVM** with feature-based modules.
|
- **Pattern**: Clean MVVM with feature-based modules
|
||||||
|
- **Language/Framework**: Swift / SwiftUI
|
||||||
|
- **Test Framework**: Swift Testing (primary), XCTest (legacy/UI tests)
|
||||||
|
- **Persistence**: SwiftData (local), CloudKit (remote sync)
|
||||||
|
- **Subscription**: StoreKit 2 via `StoreManager.shared`
|
||||||
|
|
||||||
### Three-Layer Architecture
|
### Layers
|
||||||
|
|
||||||
1. **Presentation Layer** (`Features/`): SwiftUI Views + @Observable ViewModels organized by feature (Home, Trip, Schedule, Settings)
|
1. **Presentation Layer** (`Features/`): SwiftUI Views + `@Observable` ViewModels organized by feature
|
||||||
|
- `Home/` — Main tab, saved trips list, suggested trips
|
||||||
|
- `Trip/` — Trip wizard, trip detail, itinerary editing
|
||||||
|
- `Schedule/` — Game schedule browser
|
||||||
|
- `Progress/` — Stadium visit tracking, achievements, photo import
|
||||||
|
- `Polls/` — Trip poll creation, voting
|
||||||
|
- `Settings/` — App settings, debug tools
|
||||||
|
- `Paywall/` — Pro subscription paywall
|
||||||
|
|
||||||
2. **Domain Layer** (`Planning/`): Trip planning logic
|
2. **Domain Layer** (`Planning/`): Trip planning logic
|
||||||
- `TripPlanningEngine` - Main orchestrator, 7-step algorithm
|
- `TripPlanningEngine` — Main orchestrator, scenario dispatch
|
||||||
- `RouteOptimizer` - TSP solver (exact for <8 stops, heuristic otherwise)
|
- `ScenarioAPlanner` — Date range only, finds games in range
|
||||||
- `ScheduleMatcher` - Finds games along route corridor
|
- `ScenarioBPlanner` — Selected games + date range
|
||||||
- `TripScorer` - Multi-factor scoring (game quality, route efficiency, leisure balance)
|
- `ScenarioCPlanner` — Start/end locations, games along route
|
||||||
|
- `ScenarioDPlanner` — Follow team schedule
|
||||||
|
- `ScenarioEPlanner` — Team-first, find trip windows across season
|
||||||
|
- `GameDAGRouter` — Game dependency graph routing
|
||||||
|
- `RouteFilters` — Geographic and constraint filtering
|
||||||
|
- `TravelEstimator` — Driving time/distance estimation
|
||||||
|
- `ItineraryBuilder` — Constructs final itinerary from route candidates
|
||||||
|
|
||||||
3. **Data Layer** (`Core/`):
|
3. **Data Layer** (`Core/`):
|
||||||
- `Models/Domain/` - Pure Swift structs (Trip, Game, Stadium, Team)
|
- `Models/Domain/` — Pure Swift structs (Trip, Game, Stadium, Team, TripPreferences, TripPoll)
|
||||||
- `Models/CloudKit/` - CKRecord wrappers for public database
|
- `Models/CloudKit/` — CKRecord wrappers for public database
|
||||||
- `Models/Local/` - SwiftData models for local persistence (SavedTrip, UserPreferences)
|
- `Models/Local/` — SwiftData models (SavedTrip, StadiumProgress, CanonicalModels, LocalPoll)
|
||||||
- `Services/` - CloudKitService (schedules), LocationService (geocoding/routing)
|
- `Services/` — CloudKitService, LocationService, DataProvider, AchievementEngine, PollService, BootstrapService, CanonicalSyncService, EVChargingService, FreeScoreAPI, GameMatcher, VisitPhotoService, etc.
|
||||||
|
|
||||||
4. **Export Layer** (`Export/`):
|
4. **Export Layer** (`Export/`):
|
||||||
- `PDFGenerator` - Generates PDF trip itineraries with maps, photos, and attractions
|
- `PDFGenerator` — PDF trip itineraries with maps, photos, attractions
|
||||||
- `ExportService` - Orchestrates PDF export with asset prefetching
|
- `Sharing/` — Social share cards (progress, trips, achievements)
|
||||||
- `Services/MapSnapshotService` - Generates static map images via MKMapSnapshotter
|
- `Services/` — MapSnapshotService, RemoteImageService, POISearchService, PDFAssetPrefetcher
|
||||||
- `Services/RemoteImageService` - Downloads/caches team logos and stadium photos
|
|
||||||
- `Services/POISearchService` - Finds nearby restaurants, attractions via MKLocalSearch
|
|
||||||
- `Services/PDFAssetPrefetcher` - Parallel prefetching of all PDF assets
|
|
||||||
|
|
||||||
### Data Architecture (Offline-First)
|
### Key Components
|
||||||
|
|
||||||
**CRITICAL: `AppDataProvider.shared` is the ONLY source of truth for canonical data.**
|
| Component | Type | Responsibility |
|
||||||
|
|-----------|------|----------------|
|
||||||
|
| `AppDataProvider.shared` | `@MainActor ObservableObject` singleton | Single source of truth for stadiums, teams, games, dynamic sports |
|
||||||
|
| `TripPlanningEngine` | `final class` | Dispatches to scenario-specific planners |
|
||||||
|
| `ScenarioA-EPlanner` | `final class` (each) | Scenario-specific route planning |
|
||||||
|
| `StoreManager.shared` | `@Observable @MainActor` singleton | StoreKit 2 subscriptions, entitlements |
|
||||||
|
| `AnalyticsManager.shared` | `@MainActor` singleton | PostHog analytics wrapper |
|
||||||
|
| `DesignStyleManager.shared` | Singleton | Animated vs static background toggle |
|
||||||
|
| `CloudKitService.shared` | Service | CloudKit CRUD operations |
|
||||||
|
| `CanonicalSyncService` | Service | CloudKit → SwiftData sync |
|
||||||
|
| `BootstrapService` | Service | First-launch bundled JSON → SwiftData |
|
||||||
|
| `AchievementEngine` | Service | Achievement badge evaluation |
|
||||||
|
| `PollService` | Service | Trip poll CRUD via CloudKit |
|
||||||
|
|
||||||
All code that reads stadiums, teams, games, or league structure MUST use `AppDataProvider.shared`. Never access CloudKit or SwiftData directly for this data.
|
### Data Flow
|
||||||
|
|
||||||
|
**Read (canonical data):**
|
||||||
```
|
```
|
||||||
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
|
Bundled JSON → BootstrapService → SwiftData (CanonicalStadium/Team/Game)
|
||||||
│ Bundled JSON │ │ SwiftData │ │ CloudKit │
|
↑
|
||||||
│ (App Bundle) │ │ (Local Store) │ │ (Remote Sync) │
|
CloudKit ─── CanonicalSyncService ──────┘
|
||||||
└────────┬─────────┘ └────────▲─────────┘ └────────┬─────────┘
|
↓
|
||||||
│ │ │
|
AppDataProvider.shared (in-memory cache)
|
||||||
│ Bootstrap │ Read │ Background
|
↓
|
||||||
│ (first launch) │ │ Sync
|
All Features, ViewModels, Services
|
||||||
▼ │ ▼
|
|
||||||
┌─────────────────────────────────┴────────────────────────────────────┐
|
|
||||||
│ AppDataProvider.shared │
|
|
||||||
│ (Single Source of Truth) │
|
|
||||||
└─────────────────────────────────┬────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
All Features, ViewModels, Services
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**App Startup Flow:**
|
**Write (user data):**
|
||||||
|
```
|
||||||
|
User Action → View → modelContext.insert(SwiftData model) → modelContext.save()
|
||||||
|
↓
|
||||||
|
SwiftData Local Store
|
||||||
|
```
|
||||||
|
|
||||||
|
**Planning:**
|
||||||
|
```
|
||||||
|
TripWizardView → TripWizardViewModel → TripPreferences
|
||||||
|
→ TripPlanningEngine.planTrip(preferences:)
|
||||||
|
→ ScenarioPlanner (A/B/C/D/E based on mode)
|
||||||
|
→ GameDAGRouter + RouteFilters + TravelEstimator
|
||||||
|
→ ItineraryBuilder
|
||||||
|
→ ItineraryResult (.success([ItineraryOption]) | .failure(PlanningFailure))
|
||||||
|
→ TripOptionsView → TripDetailView → SavedTrip (persist)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Access Rules
|
||||||
|
|
||||||
|
**Source of Truth**: `AppDataProvider.shared` is the ONLY source of truth for canonical data (stadiums, teams, games, league structure).
|
||||||
|
|
||||||
|
### Correct Usage
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// ✅ CORRECT — Use AppDataProvider for reads
|
||||||
|
let stadiums = AppDataProvider.shared.stadiums
|
||||||
|
let teams = AppDataProvider.shared.teams
|
||||||
|
let games = try await AppDataProvider.shared.filterGames(sports: sports, startDate: start, endDate: end)
|
||||||
|
let richGames = try await AppDataProvider.shared.filterRichGames(...)
|
||||||
|
let stadium = AppDataProvider.shared.stadium(for: stadiumId)
|
||||||
|
|
||||||
|
// ✅ CORRECT — Use modelContext for user data writes
|
||||||
|
modelContext.insert(savedTrip)
|
||||||
|
try modelContext.save()
|
||||||
|
|
||||||
|
// ✅ CORRECT — Use AnalyticsManager for events
|
||||||
|
AnalyticsManager.shared.track(.tripSaved(tripId: id, stopCount: 3, gameCount: 5))
|
||||||
|
AnalyticsManager.shared.trackScreen("TripDetail")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Wrong Usage
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// ❌ WRONG — Never access CloudKit directly for reads
|
||||||
|
let stadiums = try await CloudKitService.shared.fetchStadiums()
|
||||||
|
|
||||||
|
// ❌ WRONG — Never fetch canonical data from SwiftData directly
|
||||||
|
let descriptor = FetchDescriptor<CanonicalStadium>()
|
||||||
|
let stadiums = try context.fetch(descriptor)
|
||||||
|
|
||||||
|
// ❌ WRONG — Never call PostHog SDK directly
|
||||||
|
PostHogSDK.shared.capture("trip_saved")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Allowed Direct SwiftData Access
|
||||||
|
|
||||||
|
- `DataProvider.swift` — It IS the data provider
|
||||||
|
- `CanonicalSyncService.swift` — CloudKit → SwiftData sync
|
||||||
|
- `BootstrapService.swift` — Initial data population
|
||||||
|
- `StadiumIdentityService.swift` — Specialized identity resolution for stadium renames
|
||||||
|
- User data (`StadiumVisit`, `SavedTrip`, `LocalPoll`) — Not canonical data; read via `@Query` or `FetchDescriptor`
|
||||||
|
|
||||||
|
## Architecture Rules
|
||||||
|
|
||||||
|
- All canonical data reads MUST go through `AppDataProvider.shared`. NEVER query SwiftData or CloudKit directly for stadiums, teams, or games.
|
||||||
|
- All analytics MUST go through `AnalyticsManager.shared`. NEVER call PostHog SDK directly.
|
||||||
|
- ViewModels MUST use `@Observable` (not `ObservableObject`). Exception: `AppDataProvider` uses `ObservableObject` for `@Published` + Combine.
|
||||||
|
- All new screens MUST apply `.themedBackground()` modifier. NEVER implement custom background gradients.
|
||||||
|
- NEVER hardcode colors — use `Theme` constants from `Core/Theme/`.
|
||||||
|
- Planning engine components MUST be `final class`. They are NOT actors.
|
||||||
|
- Domain models MUST be pure `Codable` structs. SwiftData models wrap them via encoded `Data` fields.
|
||||||
|
- ALWAYS track analytics events for user actions (saves, deletes, navigation). Add cases to `AnalyticsEvent` enum.
|
||||||
|
- ALWAYS gate Pro-only features behind `StoreManager.shared.isPro`. Free trip limit is 1.
|
||||||
|
- NEVER store canonical data locally outside SwiftData — the sync pipeline expects SwiftData as the local source.
|
||||||
|
- `PDFGenerator` and `ExportService` MUST be `@MainActor final class` (not actors) because they access MainActor-isolated UI properties and use UIKit drawing.
|
||||||
|
|
||||||
|
## Mutation / Write Patterns
|
||||||
|
|
||||||
|
User data is persisted directly via SwiftData `ModelContext` — no separate repository layer.
|
||||||
|
|
||||||
|
### Save a Trip
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// ✅ CORRECT — In TripDetailView
|
||||||
|
private func saveTrip() {
|
||||||
|
guard let savedTrip = SavedTrip.from(trip, games: games, status: .planned) else { return }
|
||||||
|
modelContext.insert(savedTrip)
|
||||||
|
try modelContext.save()
|
||||||
|
AnalyticsManager.shared.track(.tripSaved(tripId: trip.id.uuidString, stopCount: count, gameCount: games))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Record a Stadium Visit
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// ✅ CORRECT — In StadiumVisitSheet
|
||||||
|
let visit = StadiumVisit(stadiumId: stadium.id, stadiumNameAtVisit: stadium.name, visitDate: date, sport: sport, ...)
|
||||||
|
modelContext.insert(visit)
|
||||||
|
try modelContext.save()
|
||||||
|
AnalyticsManager.shared.track(.stadiumVisitAdded(stadiumId: stadium.id, sport: sport.rawValue))
|
||||||
|
```
|
||||||
|
|
||||||
|
### Delete User Data
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// ✅ CORRECT — In ProgressViewModel
|
||||||
|
func deleteVisit(_ visit: StadiumVisit) async throws {
|
||||||
|
let context = ModelContext(container)
|
||||||
|
context.delete(visit)
|
||||||
|
try context.save()
|
||||||
|
await loadData() // Reload after mutation
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Add/Update Itinerary Items
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// ✅ CORRECT — Use ItineraryItemService for custom itinerary items
|
||||||
|
let item = try await ItineraryItemService.shared.createItem(newItem)
|
||||||
|
await ItineraryItemService.shared.updateItem(modifiedItem)
|
||||||
|
try await ItineraryItemService.shared.deleteItem(itemId)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Wrong Mutation
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// ❌ WRONG — Forgetting to track analytics after mutation
|
||||||
|
modelContext.insert(savedTrip)
|
||||||
|
try modelContext.save()
|
||||||
|
// Missing: AnalyticsManager.shared.track(...)
|
||||||
|
|
||||||
|
// ❌ WRONG — Writing canonical data directly (stadiums, teams, games are read-only from app perspective)
|
||||||
|
let stadium = CanonicalStadium(...)
|
||||||
|
modelContext.insert(stadium) // Only CanonicalSyncService and BootstrapService should write canonical data
|
||||||
|
|
||||||
|
// ❌ WRONG — Mutating without saving
|
||||||
|
modelContext.insert(visit)
|
||||||
|
// Missing: try modelContext.save() — changes won't persist
|
||||||
|
|
||||||
|
// ❌ WRONG — Writing user data via CloudKit instead of SwiftData
|
||||||
|
try await CloudKitService.shared.saveTrip(trip) // User data is local-only via SwiftData
|
||||||
|
```
|
||||||
|
|
||||||
|
### General Pattern
|
||||||
|
|
||||||
|
```
|
||||||
|
View → modelContext.insert(SwiftDataModel) → modelContext.save() → track analytics
|
||||||
|
View → modelContext.delete(model) → modelContext.save() → reload data → track analytics
|
||||||
|
```
|
||||||
|
|
||||||
|
## Concurrency Patterns
|
||||||
|
|
||||||
|
- **ViewModels**: `@MainActor @Observable final class`. All state updates happen on main thread.
|
||||||
|
- **AppDataProvider**: `@MainActor ObservableObject`. Singleton, configured with `ModelContext` at startup.
|
||||||
|
- **Planning Engine**: `final class` (NOT actors). Planning runs on caller's context (typically from a `Task` in ViewModel).
|
||||||
|
- **StoreManager**: `@Observable @MainActor final class`. StoreKit 2 transaction listening runs as a background `Task`.
|
||||||
|
- **CloudKit sync**: `CanonicalSyncService.syncAll()` runs async, non-blocking. Uses `SyncCancellationToken` for cancellation.
|
||||||
|
- **Export**: `PDFGenerator` and `ExportService` are `@MainActor final class` for UIKit drawing access.
|
||||||
|
- **Background refresh**: `BackgroundSyncManager` uses `BGAppRefreshTask` for periodic CloudKit sync.
|
||||||
|
|
||||||
|
### Swift 6 / Sendable Notes
|
||||||
|
|
||||||
|
- `CLLocationCoordinate2D` gets `@retroactive Codable, @retroactive Hashable, @retroactive Equatable` conformance in `TripPreferences.swift`.
|
||||||
|
- When capturing `var` in `async let`, create immutable copies first to avoid Swift 6 warnings.
|
||||||
|
- `CloudKitContainerConfig.identifier` and `makeContainer()` are `nonisolated static` for cross-isolation access.
|
||||||
|
|
||||||
|
## State Management
|
||||||
|
|
||||||
|
### Cache / Offline Behavior
|
||||||
|
|
||||||
|
- **AppDataProvider**: Loads canonical data from SwiftData into in-memory dictionaries at startup. O(1) lookups via `teamsById`, `stadiumsById`.
|
||||||
|
- **SwiftData always has data**: From bootstrap (bundled JSON) or last successful CloudKit sync.
|
||||||
|
- **First launch + offline**: Bootstrap data used. App is fully functional.
|
||||||
|
- **CloudKit unavailable**: App continues with existing local data. Sync retries on next launch.
|
||||||
|
- **LRUCache**: Used for caching remote images and map snapshots during PDF export.
|
||||||
|
|
||||||
|
### Startup Flow
|
||||||
|
|
||||||
1. **Bootstrap** (first launch only): `BootstrapService` loads bundled JSON → SwiftData
|
1. **Bootstrap** (first launch only): `BootstrapService` loads bundled JSON → SwiftData
|
||||||
2. **Configure**: `AppDataProvider.shared.configure(with: context)`
|
2. **Configure**: `AppDataProvider.shared.configure(with: context)`
|
||||||
3. **Load**: `AppDataProvider.shared.loadInitialData()` reads SwiftData into memory
|
3. **Load**: `AppDataProvider.shared.loadInitialData()` reads SwiftData into memory
|
||||||
4. **App usable immediately** with local data
|
4. **App usable immediately** with local data
|
||||||
5. **Background sync**: `CanonicalSyncService.syncAll()` fetches CloudKit → updates SwiftData (non-blocking)
|
5. **Background sync**: `CanonicalSyncService.syncAll()` fetches CloudKit → updates SwiftData (non-blocking)
|
||||||
6. **Reload**: After sync completes, `loadInitialData()` refreshes in-memory cache
|
6. **Reload**: After sync completes, `loadInitialData()` refreshes in-memory cache
|
||||||
|
7. **Analytics init**: `AnalyticsManager` initialized, super properties set
|
||||||
**Offline Handling:**
|
|
||||||
- SwiftData always has data (from bootstrap or last successful CloudKit sync)
|
|
||||||
- If CloudKit unavailable, app continues with existing local data
|
|
||||||
- First launch + offline = bootstrap data used
|
|
||||||
|
|
||||||
**Canonical Data Models** (in SwiftData, synced from CloudKit):
|
**Canonical Data Models** (in SwiftData, synced from CloudKit):
|
||||||
- `CanonicalStadium` → `Stadium` (domain)
|
- `CanonicalStadium` → `Stadium` (domain)
|
||||||
- `CanonicalTeam` → `Team` (domain)
|
- `CanonicalTeam` → `Team` (domain)
|
||||||
- `CanonicalGame` → `Game` (domain)
|
- `CanonicalGame` → `Game` (domain)
|
||||||
- `LeagueStructureModel`
|
- `LeagueStructureModel`, `TeamAlias`, `StadiumAlias`
|
||||||
- `TeamAlias`, `StadiumAlias`
|
|
||||||
|
|
||||||
**User Data Models** (local only, not synced):
|
**User Data Models** (local only, not synced):
|
||||||
- `SavedTrip`, `StadiumVisit`, `UserPreferences`, `Achievement`
|
- `SavedTrip`, `StadiumVisit`, `StadiumProgress`, `UserPreferences`, `Achievement`, `LocalPoll`
|
||||||
|
|
||||||
|
## Test Conventions
|
||||||
|
|
||||||
|
### Framework & Location
|
||||||
|
|
||||||
|
- **Framework**: Swift Testing (`import Testing`, `@Test`, `#expect`, `@Suite`) for all new tests
|
||||||
|
- **Legacy**: One XCTest file (`ItineraryConstraintsTests.swift`) — do not add new XCTest tests
|
||||||
|
- **Test directory**: `SportsTimeTests/` — mirrors source structure
|
||||||
|
- **File naming**: `{ClassName}Tests.swift` or `{Feature}Tests.swift`
|
||||||
|
- **Helper files**: `SportsTimeTests/Helpers/MockServices.swift`, `SportsTimeTests/Helpers/TestFixtures.swift`
|
||||||
|
- **UI tests**: `SportsTimeUITests/` (template only, not actively used)
|
||||||
|
|
||||||
|
### Existing Test Suites
|
||||||
|
|
||||||
|
| Directory | Suite | Approx Tests | Covers |
|
||||||
|
|-----------|-------|-------------|--------|
|
||||||
|
| `Domain/` | `AchievementDefinitionsTests` | 31 | Achievement badge definitions |
|
||||||
|
| `Domain/` | `AnySportTests` | 13 | Sport type erasure |
|
||||||
|
| `Domain/` | `DivisionTests` | 31 | League divisions |
|
||||||
|
| `Domain/` | `DynamicSportTests` | 13 | Dynamic sport configuration |
|
||||||
|
| `Domain/` | `GameTests` | 9 | Game model |
|
||||||
|
| `Domain/` | `ProgressTests` | 39 | Progress tracking calculations |
|
||||||
|
| `Domain/` | `RegionTests` | 13 | Geographic regions |
|
||||||
|
| `Domain/` | `SportTests` | 17 | Sport enum |
|
||||||
|
| `Domain/` | `StadiumTests` | 16 | Stadium model |
|
||||||
|
| `Domain/` | `TeamTests` | 10 | Team model |
|
||||||
|
| `Domain/` | `TravelInfoTests` | 3 | Travel info |
|
||||||
|
| `Domain/` | `TravelSegmentTests` | 19 | Travel segment model |
|
||||||
|
| `Domain/` | `TripPollTests` | 25 | Poll model |
|
||||||
|
| `Domain/` | `TripPreferencesTests` | 31 | Trip preferences, leisure levels |
|
||||||
|
| `Domain/` | `TripStopTests` | 14 | Trip stop model |
|
||||||
|
| `Domain/` | `TripTests` | 27 | Trip model |
|
||||||
|
| `Planning/` | `TripPlanningEngineTests` | 13 | Engine orchestration, planning modes |
|
||||||
|
| `Planning/` | `ScenarioA-EPlannerTests` | ~50 | Individual scenario planners |
|
||||||
|
| `Planning/` | `ScenarioPlannerFactoryTests` | 11 | Factory dispatch |
|
||||||
|
| `Planning/` | `GameDAGRouterTests` | ~15 | Game dependency routing |
|
||||||
|
| `Planning/` | `ItineraryBuilderTests` | ~10 | Itinerary construction |
|
||||||
|
| `Planning/` | `RouteFiltersTests` | ~10 | Route filtering |
|
||||||
|
| `Planning/` | `TravelEstimatorTests` | 37 | Distance/time estimation |
|
||||||
|
| `Planning/` | `TeamFirstIntegrationTests` | 6 | Team-first integration |
|
||||||
|
| `Planning/` | `PlanningModelsTests` | ~10 | Planning model types |
|
||||||
|
| `Services/` | `AchievementEngineTests` | 22 | Achievement evaluation |
|
||||||
|
| `Services/` | `DataProviderTests` | 3 | DataProvider errors |
|
||||||
|
| `Services/` | `DeepLinkHandlerTests` | 12 | Deep link parsing |
|
||||||
|
| `Services/` | `EVChargingServiceTests` | 16 | EV charging stops |
|
||||||
|
| `Services/` | `FreeScoreAPITests` | 36 | Score API integration |
|
||||||
|
| `Services/` | `GameMatcherTests` | 18 | Game matching logic |
|
||||||
|
| `Services/` | `HistoricalGameScraperTests` | 13 | Historical data scraping |
|
||||||
|
| `Services/` | `LocationServiceTests` | 27 | Geocoding, routing |
|
||||||
|
| `Services/` | `PhotoMetadataExtractorTests` | 14 | Photo EXIF extraction |
|
||||||
|
| `Services/` | `PollServiceTests` | 9 | Poll CRUD |
|
||||||
|
| `Services/` | `RateLimiterTests` | 8 | API rate limiting |
|
||||||
|
| `Services/` | `RouteDescriptionGeneratorTests` | 16 | Route description text |
|
||||||
|
| `Services/` | `ScoreResolutionCacheTests` | 17 | Score caching |
|
||||||
|
| `Services/` | `StadiumProximityMatcherTests` | 40 | Stadium proximity matching |
|
||||||
|
| `Services/` | `SuggestedTripsGeneratorTests` | 20 | Trip suggestions |
|
||||||
|
| `Services/` | `VisitPhotoServiceTests` | 9 | Visit photo management |
|
||||||
|
| `Export/` | `PDFGeneratorTests` | 16 | PDF generation |
|
||||||
|
| `Export/` | `MapSnapshotServiceTests` | 5 | Map snapshots |
|
||||||
|
| `Export/` | `POISearchServiceTests` | 14 | POI search |
|
||||||
|
| `Export/` | `ShareableContentTests` | 23 | Share card generation |
|
||||||
|
| `Features/Trip/` | `ItineraryReorderingLogicTests` | 53 | Itinerary reordering |
|
||||||
|
| `Features/Trip/` | `ItinerarySemanticTravelTests` | 25 | Semantic travel placement |
|
||||||
|
| `Features/Trip/` | `RegionMapSelectorTests` | 14 | Region map selection |
|
||||||
|
| `Features/Trip/` | `TravelPlacementTests` | 13 | Travel segment placement |
|
||||||
|
| `Features/Trip/` | `ItineraryRowFlatteningTests` | 11 | Row flattening |
|
||||||
|
| `Features/Trip/` | `ItineraryTravelConstraintTests` | 11 | Travel constraints |
|
||||||
|
| `Features/Trip/` | `ItinerarySortOrderTests` | 8 | Sort order |
|
||||||
|
| `Features/Trip/` | `ItineraryCustomItemTests` | 7 | Custom items |
|
||||||
|
| `Features/Trip/` | `ItineraryReorderingTests` | 7 | Reordering |
|
||||||
|
| `Features/Trip/` | `ItineraryEdgeCaseTests` | 4 | Edge cases |
|
||||||
|
| `Features/Polls/` | `PollVotingViewModelTests` | 6 | Poll voting ViewModel |
|
||||||
|
| Root | `ItineraryConstraintsTests` | ~10 | Itinerary constraints (XCTest) |
|
||||||
|
|
||||||
|
**Total: ~900+ tests**
|
||||||
|
|
||||||
|
### Naming Convention
|
||||||
|
|
||||||
|
Swift Testing style with `@Suite` and `@Test`:
|
||||||
|
|
||||||
**Correct Usage:**
|
|
||||||
```swift
|
```swift
|
||||||
// ✅ CORRECT - Use AppDataProvider
|
@Suite("TripPlanningEngine")
|
||||||
let stadiums = AppDataProvider.shared.stadiums
|
struct TripPlanningEngineTests {
|
||||||
let teams = AppDataProvider.shared.teams
|
|
||||||
let games = try await AppDataProvider.shared.filterGames(sports: sports, startDate: start, endDate: end)
|
|
||||||
let richGames = try await AppDataProvider.shared.filterRichGames(...)
|
|
||||||
let allGames = try await AppDataProvider.shared.allGames(for: sports)
|
|
||||||
|
|
||||||
// ❌ WRONG - Never access CloudKit directly for reads
|
@Test("planningMode: dateRange is valid mode")
|
||||||
let stadiums = try await CloudKitService.shared.fetchStadiums() // NO!
|
func planningMode_dateRange() {
|
||||||
|
// ...
|
||||||
|
#expect(prefs.planningMode == .dateRange)
|
||||||
|
}
|
||||||
|
|
||||||
// ❌ WRONG - Never fetch canonical data from SwiftData directly
|
@Test("DrivingConstraints: clamps negative drivers to 1")
|
||||||
let descriptor = FetchDescriptor<CanonicalStadium>()
|
func drivingConstraints_clampsNegativeDrivers() {
|
||||||
let stadiums = try context.fetch(descriptor) // NO! (except in DataProvider/Sync/Bootstrap)
|
// ...
|
||||||
|
}
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Allowed Direct SwiftData Access:**
|
Pattern: `func {component}_{behavior}()` with descriptive `@Test("...")` display name.
|
||||||
- `DataProvider.swift` - It IS the data provider
|
|
||||||
- `CanonicalSyncService.swift` - CloudKit → SwiftData sync
|
|
||||||
- `BootstrapService.swift` - Initial data population
|
|
||||||
- `StadiumIdentityService.swift` - Specialized identity resolution for stadium renames
|
|
||||||
- User data (e.g., `StadiumVisit`) - Not canonical data
|
|
||||||
|
|
||||||
### Key Data Flow
|
Example: `func drivingConstraints_maxDailyHours()`, `func planningMode_followTeam()`
|
||||||
|
|
||||||
```
|
### Mocking Strategy
|
||||||
TripCreationView → TripCreationViewModel → PlanningRequest
|
|
||||||
→ TripPlanningEngine (ScheduleMatcher + RouteOptimizer + TripScorer)
|
- **Mock services**: `SportsTimeTests/Helpers/MockServices.swift` provides `MockDataProvider`, `MockLocationService`, `MockRouteService`
|
||||||
→ PlanningResult → Trip → TripDetailView → SavedTrip (persist)
|
- **Test fixtures**: `SportsTimeTests/Helpers/TestFixtures.swift` provides `TestFixtures.game()`, `TestFixtures.stadium()`, `TestFixtures.team()` factory methods with realistic defaults
|
||||||
|
- **What to mock**: CloudKit, location services, route calculations, network calls
|
||||||
|
- **What to use real**: Domain models, pure computation (planning logic, scoring, filtering)
|
||||||
|
- **No SwiftData mocking yet**: Tests that need SwiftData use domain model factories instead of SwiftData models
|
||||||
|
|
||||||
|
```swift
|
||||||
|
// Test setup pattern
|
||||||
|
@Suite("TripPlanningEngine")
|
||||||
|
struct TripPlanningEngineTests {
|
||||||
|
private let nycCoord = CLLocationCoordinate2D(latitude: 40.7580, longitude: -73.9855)
|
||||||
|
|
||||||
|
@Test("planningMode: dateRange is valid mode")
|
||||||
|
func planningMode_dateRange() {
|
||||||
|
let prefs = TripPreferences(planningMode: .dateRange, sports: [.mlb])
|
||||||
|
#expect(prefs.planningMode == .dateRange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Using fixtures
|
||||||
|
let game = TestFixtures.game(sport: .mlb, city: "Boston")
|
||||||
|
let stadium = TestFixtures.stadium(sport: .mlb, city: "Boston")
|
||||||
```
|
```
|
||||||
|
|
||||||
## Important Patterns
|
### Bug Fix Protocol
|
||||||
|
|
||||||
- ViewModels use `@Observable` (not ObservableObject)
|
When fixing a bug:
|
||||||
- All planning engine components are `actor` types for thread safety
|
1. Write a regression test that reproduces the bug BEFORE fixing it
|
||||||
- Domain models are pure Codable structs; SwiftData models wrap them via encoded `Data` fields
|
2. Include edge cases — test boundary conditions, nil/empty inputs, related scenarios
|
||||||
- CloudKit container ID: `iCloud.com.88oakapps.SportsTime`
|
3. Confirm all tests pass after the fix
|
||||||
- `PDFGenerator` and `ExportService` are `@MainActor final class` (not actors) because they access MainActor-isolated UI properties and use UIKit drawing
|
4. Name tests descriptively — e.g., `test_DayCard_OnlyShowsGamesFromPrimaryStop_WhenMultipleStopsOverlapSameDay`
|
||||||
|
|
||||||
### Analytics (PostHog)
|
## Known Edge Cases & Gotchas
|
||||||
|
|
||||||
All analytics go through `AnalyticsManager.shared` — never call PostHog SDK directly.
|
### Planning Engine Input Validation
|
||||||
|
|
||||||
**Architecture** (`Core/Analytics/`):
|
`TripPreferences` is the input to the planning engine. Key constraints:
|
||||||
- `AnalyticsManager.swift` - `@MainActor` singleton wrapping PostHogSDK. Handles init, tracking, opt-in/out, super properties, session replay.
|
|
||||||
- `AnalyticsEvent.swift` - Type-safe enum with ~40 event cases, each with `name: String` and `properties: [String: Any]`.
|
|
||||||
|
|
||||||
**Self-hosted backend:** `https://analytics.88oakapps.com`
|
| Field | Type | Default | Validation |
|
||||||
**API key:** Set in `AnalyticsManager.apiKey` (replace placeholder before shipping)
|
|-------|------|---------|------------|
|
||||||
|
| `planningMode` | `PlanningMode` | `.dateRange` | Required. Determines which scenario planner runs |
|
||||||
|
| `sports` | `Set<Sport>` | `[]` | At least one sport should be selected |
|
||||||
|
| `startDate` / `endDate` | `Date` | Now / Now+7d | `endDate > startDate` required for valid `dateRange` |
|
||||||
|
| `numberOfDrivers` | `Int` | 1 | Clamped to `max(1, value)` by `DrivingConstraints` |
|
||||||
|
| `maxDrivingHoursPerDriver` | `Double?` | `nil` (→ 8.0) | Clamped to `max(1.0, value)` by `DrivingConstraints` |
|
||||||
|
| `numberOfStops` | `Int?` | `nil` | Optional cap on stops |
|
||||||
|
| `leisureLevel` | `LeisureLevel` | `.moderate` | packed/moderate/relaxed — affects rest days and max games |
|
||||||
|
| `allowRepeatCities` | `Bool` | `true` | When false, engine rejects routes visiting same city twice |
|
||||||
|
| `selectedRegions` | `Set<Region>` | All 3 (east, central, west) | Filters available stadiums |
|
||||||
|
|
||||||
|
**Mode-specific requirements:**
|
||||||
|
- `.dateRange`: Needs `sports`, date range
|
||||||
|
- `.gameFirst`: Needs `mustSeeGameIds` (selected games)
|
||||||
|
- `.locations`: Needs `startLocation` + `endLocation` (with resolved coordinates)
|
||||||
|
- `.followTeam`: Needs `followTeamId`
|
||||||
|
- `.teamFirst`: Needs `selectedTeamIds`
|
||||||
|
|
||||||
|
**Engine failure modes** (returns `PlanningFailure`):
|
||||||
|
- `noGamesInRange` — No games found within date range
|
||||||
|
- `noValidRoutes` — No valid routes could be constructed
|
||||||
|
- `missingDateRange` / `missingLocations` / `missingTeamSelection` — Required fields missing
|
||||||
|
- `dateRangeViolation` — Selected games fall outside date range
|
||||||
|
- `drivingExceedsLimit` — Driving time exceeds daily limit
|
||||||
|
- `repeatCityViolation` — Route visits same city on multiple days (when `allowRepeatCities = false`)
|
||||||
|
- `geographicBacktracking` — Route requires excessive backtracking
|
||||||
|
|
||||||
|
### Platform Gotchas
|
||||||
|
|
||||||
|
**iOS 26 Deprecated APIs:**
|
||||||
|
- `CLGeocoder` → Use `MKLocalSearch` with `.address` result type instead
|
||||||
|
- `MKPlacemark` properties (locality, administrativeArea, etc.) → Still work but deprecated
|
||||||
|
- `MKMapItem.location` is non-optional in iOS 26
|
||||||
|
|
||||||
|
### Framework Gotchas
|
||||||
|
|
||||||
|
- **SwiftData context threading**: `ModelContext` is not `Sendable`. Create contexts on the thread/actor where they'll be used.
|
||||||
|
- **CloudKit sync timing**: Background sync is non-blocking. Data may be stale until sync completes and `loadInitialData()` is called again.
|
||||||
|
- **CloudKit container**: Single container `iCloud.com.88oakapps.SportsTime`. No dev/prod toggle — same container for all environments.
|
||||||
|
- **StoreKit testing**: `debugProOverride` defaults to `true` in DEBUG builds, bypassing real subscription checks.
|
||||||
|
- **ETag sensitivity**: `AppDataProvider` uses ETag caching for lookups. If CloudKit data changes but SwiftData isn't synced, stale data persists until next sync.
|
||||||
|
|
||||||
|
### Data Gotchas
|
||||||
|
|
||||||
|
- **Duplicate game IDs**: JSON data can have duplicate game IDs (rescheduled games). Regression tests exist (`DuplicateGameIdTests`).
|
||||||
|
- **Timezone handling**: Games store `dateTime` in UTC. Display uses stadium's local timezone. Timezone edge cases near midnight can show game on wrong calendar day.
|
||||||
|
- **Stadium renames**: `StadiumIdentityService` resolves old stadium names to current canonical IDs via `StadiumAlias`.
|
||||||
|
- **Historical scores**: `FreeScoreAPI` scrapes historical game scores. Rate-limited to avoid bans.
|
||||||
|
|
||||||
|
### Common Developer Mistakes
|
||||||
|
|
||||||
|
- Querying `CanonicalStadium` directly from SwiftData instead of using `AppDataProvider.shared` — causes stale data and bypasses the canonical ID lookup
|
||||||
|
- Calling `PostHogSDK.shared.capture()` directly instead of `AnalyticsManager.shared.track()` — bypasses opt-out, super properties, and type-safe event definitions
|
||||||
|
- Forgetting `.themedBackground()` on new screens — breaks visual consistency
|
||||||
|
- Using `ObservableObject` for new ViewModels instead of `@Observable` — inconsistent with codebase pattern
|
||||||
|
- Creating planning engine components as `actor` instead of `final class` — they use `@MainActor` isolation from callers
|
||||||
|
- Not gating features behind `StoreManager.shared.isPro` — lets free users access Pro features
|
||||||
|
|
||||||
|
## External Boundaries
|
||||||
|
|
||||||
|
| Boundary | Handler Class | Input Source | What Could Go Wrong |
|
||||||
|
|----------|--------------|-------------|---------------------|
|
||||||
|
| CloudKit (public DB) | `CloudKitService` | Apple servers | Quota limits, throttling, schema mismatch, network failure |
|
||||||
|
| CloudKit (sync) | `CanonicalSyncService` | Apple servers | Partial sync, conflict resolution, cancellation |
|
||||||
|
| MKDirections API | `LocationService` | Apple Maps | Rate limiting, no route found, network failure |
|
||||||
|
| MKLocalSearch | `POISearchService` | Apple Maps | No results, deprecated API responses |
|
||||||
|
| MKMapSnapshotter | `MapSnapshotService` | Apple Maps | Timeout, memory pressure on large maps |
|
||||||
|
| StoreKit 2 | `StoreManager` | App Store | Invalid product IDs, sandbox vs production, receipt validation |
|
||||||
|
| PostHog | `AnalyticsManager` | analytics.88oakapps.com | Server unreachable, opt-out state |
|
||||||
|
| Score APIs | `FreeScoreAPI` | Third-party sports sites | Rate limiting, HTML parsing failures, site changes |
|
||||||
|
| Historical scraper | `HistoricalGameScraper` | Third-party reference sites | Rate limiting, HTML structure changes |
|
||||||
|
| Bundled JSON | `BootstrapService` | App bundle | Corrupted JSON, schema version mismatch |
|
||||||
|
| Photo library | `VisitPhotoService` | PHPicker | Permission denied, large photos, HEIF format |
|
||||||
|
| Deep links | `DeepLinkHandler` | URL schemes | Malformed URLs, missing parameters |
|
||||||
|
|
||||||
|
## Analytics (PostHog)
|
||||||
|
|
||||||
|
All analytics go through `AnalyticsManager.shared` — NEVER call PostHog SDK directly.
|
||||||
|
|
||||||
|
- **SDK**: PostHog iOS (`posthog-ios` v3.41.0)
|
||||||
|
- **Manager**: `AnalyticsManager.shared` (`Core/Analytics/AnalyticsManager.swift`) — `@MainActor` singleton
|
||||||
|
- **Events**: `AnalyticsEvent` enum (`Core/Analytics/AnalyticsEvent.swift`) — ~40 type-safe event cases with `name` and `properties`
|
||||||
|
- **Self-hosted backend**: `https://analytics.88oakapps.com`
|
||||||
|
|
||||||
**Features enabled:**
|
**Features enabled:**
|
||||||
- Event capture + autocapture
|
- Event capture + autocapture
|
||||||
- Session replay (`screenshotMode` for SwiftUI, text inputs masked)
|
- Session replay (screenshotMode for SwiftUI, text inputs masked)
|
||||||
- Network telemetry capture
|
- Network telemetry capture
|
||||||
- Super properties (app version, device model, OS, pro status, selected sports)
|
- Super properties (app version, device model, OS, pro status, selected sports)
|
||||||
- Privacy opt-out toggle in Settings (persisted via UserDefaults `"analyticsOptedOut"`)
|
- Privacy opt-out toggle in Settings (persisted via UserDefaults `"analyticsOptedOut"`)
|
||||||
@@ -165,170 +531,110 @@ case myNewEvent(param: String)
|
|||||||
AnalyticsManager.shared.track(.myNewEvent(param: "value"))
|
AnalyticsManager.shared.track(.myNewEvent(param: "value"))
|
||||||
```
|
```
|
||||||
|
|
||||||
**Correct Usage:**
|
## Environment Configuration
|
||||||
```swift
|
|
||||||
// ✅ CORRECT - Use AnalyticsManager
|
|
||||||
AnalyticsManager.shared.track(.tripSaved(tripId: id, stopCount: 3, gameCount: 5))
|
|
||||||
AnalyticsManager.shared.trackScreen("TripDetail")
|
|
||||||
|
|
||||||
// ❌ WRONG - Never call PostHog SDK directly
|
- **CloudKit container**: `iCloud.com.88oakapps.SportsTime` (single container, no dev/prod split)
|
||||||
PostHogSDK.shared.capture("trip_saved") // NO!
|
- **StoreKit product IDs**: `com.88oakapps.SportsTime.pro.monthly`, `com.88oakapps.SportsTime.pro.annual2`
|
||||||
```
|
- **Debug Pro override**: In DEBUG builds, `StoreManager.shared.debugProOverride` defaults to `true` (bypasses subscription check). Toggle in Settings debug section.
|
||||||
|
- **Analytics**: Self-hosted PostHog at `https://analytics.88oakapps.com`. API key set in `AnalyticsManager.apiKey`.
|
||||||
|
- **Data scraping**: Python scripts in `Scripts/` with `requirements.txt`. See `Scripts/DATA_SOURCES.md` for source URLs and rate limits.
|
||||||
|
|
||||||
**Initialization:** Called during app bootstrap in `SportsTimeApp.performBootstrap()` (Step 7). Super properties refreshed on `.active` scene phase. Flushed on `.background`.
|
## Directory Conventions
|
||||||
|
|
||||||
### Themed Background System
|
When adding new files:
|
||||||
|
|
||||||
|
- **New features**: `Features/{FeatureName}/Views/` and `Features/{FeatureName}/ViewModels/`
|
||||||
|
- **New views**: `Features/{Feature}/Views/{ViewName}.swift`
|
||||||
|
- **New ViewModels**: `Features/{Feature}/ViewModels/{Feature}ViewModel.swift`
|
||||||
|
- **New domain models**: `Core/Models/Domain/{ModelName}.swift`
|
||||||
|
- **New SwiftData models**: `Core/Models/Local/{ModelName}.swift`
|
||||||
|
- **New services**: `Core/Services/{ServiceName}.swift`
|
||||||
|
- **New planning components**: `Planning/Engine/{ComponentName}.swift`
|
||||||
|
- **New planning models**: `Planning/Models/{ModelName}.swift`
|
||||||
|
- **New export services**: `Export/Services/{ServiceName}.swift`
|
||||||
|
- **New sharing cards**: `Export/Sharing/{CardName}.swift`
|
||||||
|
- **New tests**: `SportsTimeTests/{matching source directory}/{ClassName}Tests.swift`
|
||||||
|
- **New test helpers**: `SportsTimeTests/Helpers/`
|
||||||
|
|
||||||
|
### Naming Conventions
|
||||||
|
|
||||||
|
- **ViewModels**: `{Feature}ViewModel.swift` — e.g., `ProgressViewModel.swift`, `TripWizardViewModel.swift`
|
||||||
|
- **Views**: `{DescriptiveName}View.swift` — e.g., `TripDetailView.swift`, `StadiumVisitSheet.swift`
|
||||||
|
- **Domain models**: `{ModelName}.swift` — e.g., `Trip.swift`, `Stadium.swift`, `TripPreferences.swift`
|
||||||
|
- **Services**: `{ServiceName}.swift` — e.g., `CloudKitService.swift`, `AchievementEngine.swift`
|
||||||
|
- **Tests**: `{ClassName}Tests.swift` — e.g., `TripPlanningEngineTests.swift`, `StadiumProximityMatcherTests.swift`
|
||||||
|
- **Test fixtures**: Use `TestFixtures.{model}()` factory methods
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
### Package Manager
|
||||||
|
|
||||||
|
- **SPM** (Swift Package Manager)
|
||||||
|
- **Lock file**: `SportsTime.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved`
|
||||||
|
- **Manifest**: Xcode project settings (no standalone `Package.swift`)
|
||||||
|
|
||||||
|
### Key Dependencies
|
||||||
|
|
||||||
|
| Package | Purpose | Version |
|
||||||
|
|---------|---------|---------|
|
||||||
|
| `posthog-ios` | Analytics (PostHog SDK) | 3.41.0 |
|
||||||
|
| `LRUCache` | In-memory cache (images, map snapshots) | 1.2.1 |
|
||||||
|
| `SwiftSoup` | HTML parsing (score scraping) | 2.11.3 |
|
||||||
|
| `swift-atomics` | Thread-safe primitives (transitive dep) | 1.3.0 |
|
||||||
|
|
||||||
|
## Themed Background System
|
||||||
|
|
||||||
All views use `.themedBackground()` modifier for consistent backgrounds app-wide.
|
All views use `.themedBackground()` modifier for consistent backgrounds app-wide.
|
||||||
|
|
||||||
**Components** (`Core/Theme/`):
|
**Components** (`Core/Theme/`):
|
||||||
- `ThemedBackground` (ViewModifiers.swift) - Conditionally shows static gradient or animated background
|
- `ThemedBackground` (ViewModifiers) — Conditionally shows static gradient or animated background
|
||||||
- `AnimatedSportsBackground` (AnimatedBackground.swift) - Floating sports icons with route lines
|
- `AnimatedSportsBackground` — Floating sports icons with route lines
|
||||||
- `DesignStyleManager.shared.animationsEnabled` - Toggle controlled in Settings
|
- `DesignStyleManager.shared.animationsEnabled` — Toggle controlled in Settings
|
||||||
|
|
||||||
**How it works:**
|
|
||||||
```swift
|
```swift
|
||||||
// All views apply this modifier - animation state is automatic
|
// All views apply this modifier - animation state is automatic
|
||||||
.themedBackground()
|
.themedBackground()
|
||||||
|
|
||||||
// The modifier checks DesignStyleManager internally:
|
|
||||||
if DesignStyleManager.shared.animationsEnabled {
|
|
||||||
AnimatedSportsBackground() // Floating icons + route lines
|
|
||||||
} else {
|
|
||||||
Theme.backgroundGradient(colorScheme) // Static gradient
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Adding new screens:** Just apply `.themedBackground()` - no need to handle animation logic.
|
|
||||||
|
|
||||||
### iOS 26 API Notes
|
|
||||||
|
|
||||||
**Deprecated APIs** (use with `@available(iOS, deprecated: 26.0)` annotation):
|
|
||||||
- `CLGeocoder` → Use `MKLocalSearch` with `.address` result type instead
|
|
||||||
- `MKPlacemark` properties (locality, administrativeArea, etc.) → Still work but deprecated; use `MKMapItem` properties where possible
|
|
||||||
- `MKMapItem.location` is non-optional in iOS 26 (returns `CLLocation`, not `CLLocation?`)
|
|
||||||
|
|
||||||
**Swift 6 Concurrency**:
|
|
||||||
- Use `@retroactive` for protocol conformances on types you don't own (e.g., `CLLocationCoordinate2D: @retroactive Codable`)
|
|
||||||
- When capturing `var` in `async let`, create immutable copies first to avoid Swift 6 warnings
|
|
||||||
|
|
||||||
## Key View Components
|
## Key View Components
|
||||||
|
|
||||||
### TripDetailView (`Features/Trip/Views/TripDetailView.swift`)
|
### TripDetailView (`Features/Trip/Views/TripDetailView.swift`)
|
||||||
|
|
||||||
Displays trip itinerary with conflict detection for same-day games in different cities.
|
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
|
||||||
- `detectConflicts(for: ItineraryDay)` - Checks if multiple stops have games on the same calendar day
|
- **RouteOptionsCard**: Expandable card for conflicting routes (orange border, branch icon)
|
||||||
- Returns `DayConflictInfo` with `hasConflict`, `conflictingStops`, and `conflictingCities`
|
- **DayCard**: Non-conflict single-route display
|
||||||
|
- Supports itinerary reordering, custom item addition, travel day overrides
|
||||||
**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
|
||||||
|
|
||||||
`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.
|
`Scripts/scrape_schedules.py` scrapes NBA/MLB/NHL schedules from multiple sources for cross-validation. See `Scripts/DATA_SOURCES.md` for source URLs and rate limits.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
The `docs/` directory contains project documentation:
|
- `docs/MARKET_RESEARCH.md` — Competitive analysis and feature recommendations
|
||||||
- `MARKET_RESEARCH.md` - Competitive analysis and feature recommendations based on sports travel app market research (January 2026)
|
- `ARCHITECTURE.md` — High-level architecture overview
|
||||||
|
- `PROJECT_STATE.md` — Current project state and progress
|
||||||
## 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
|
## Future Phases
|
||||||
|
|
||||||
See `docs/MARKET_RESEARCH.md` for full competitive analysis and feature prioritization.
|
See `docs/MARKET_RESEARCH.md` for full competitive analysis and feature prioritization.
|
||||||
|
|
||||||
### Phase 2: AI-Powered Trip Planning
|
### Phase 2: AI-Powered Trip Planning
|
||||||
|
- Natural language trip planning via Apple Foundation Models (iOS 26+)
|
||||||
**Natural Language Trip Planning**
|
- On-device intelligence, privacy-preserving
|
||||||
- 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"
|
- `@Generable` for structured output parsing
|
||||||
- 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
|
|
||||||
|
|
||||||
### Phase 3: Stadium Bucket List
|
### Phase 3: Stadium Bucket List
|
||||||
|
- Visual map of visited vs remaining stadiums
|
||||||
**Progress Tracking**
|
- Digital passport/stamps, achievement badges
|
||||||
- Visual map showing visited vs. remaining stadiums per league
|
- Shareable progress cards
|
||||||
- Digital passport/stamps for each visited stadium
|
|
||||||
- Achievement badges (e.g., "All NL West", "Coast to Coast", "10 Stadiums")
|
|
||||||
- Shareable progress cards for social media
|
|
||||||
|
|
||||||
**Competitors**: Baseball Bucket List, Sports Venue Tracker, MLB BallPark Pass-Port (physical)
|
|
||||||
|
|
||||||
### Phase 4: Group Trip Coordination
|
### Phase 4: Group Trip Coordination
|
||||||
|
- Collaborative planning, polling/voting (partially implemented via `PollService`)
|
||||||
**Collaborative Planning**
|
- Expense splitting, shared itinerary with real-time sync
|
||||||
- Invite friends to collaborate on trip planning
|
|
||||||
- Polling/voting on game choices and destinations
|
|
||||||
- Expense splitting integration
|
|
||||||
- Shared itinerary with real-time sync
|
|
||||||
- Role delegation (lodging, tickets, restaurants)
|
|
||||||
|
|
||||||
**Competitors**: SquadTrip, Troupe, Howbout
|
|
||||||
|
|
||||||
### Phase 5: Fan Community
|
### Phase 5: Fan Community
|
||||||
|
- Stadium tips from locals, fan meetup coordination
|
||||||
**Social Features**
|
|
||||||
- Stadium tips from locals (best food, parking, pre-game bars)
|
|
||||||
- Fan meetup coordination for away games
|
|
||||||
- Trip reviews and ratings
|
- Trip reviews and ratings
|
||||||
- Discussion forums for specific stadiums
|
|
||||||
|
|
||||||
**Competitor**: Fantrip (fan-to-fan stays and local tips)
|
|
||||||
|
|
||||||
## User Instruction
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user