diff --git a/CLAUDE.md b/CLAUDE.md index a724730..30ba2d5 100644 --- a/CLAUDE.md +++ b/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 @@ -8,14 +8,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co # Build the iOS app 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 # 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 +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) cd Scripts && pip install -r requirements.txt @@ -24,133 +24,499 @@ 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. +- **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 - - `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) + - `TripPlanningEngine` — Main orchestrator, scenario dispatch + - `ScenarioAPlanner` — Date range only, finds games in range + - `ScenarioBPlanner` — Selected games + date range + - `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/`): - - `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) + - `Models/Domain/` — Pure Swift structs (Trip, Game, Stadium, Team, TripPreferences, TripPoll) + - `Models/CloudKit/` — CKRecord wrappers for public database + - `Models/Local/` — SwiftData models (SavedTrip, StadiumProgress, CanonicalModels, LocalPoll) + - `Services/` — CloudKitService, LocationService, DataProvider, AchievementEngine, PollService, BootstrapService, CanonicalSyncService, EVChargingService, FreeScoreAPI, GameMatcher, VisitPhotoService, etc. 4. **Export Layer** (`Export/`): - - `PDFGenerator` - Generates PDF trip itineraries with maps, photos, and attractions - - `ExportService` - Orchestrates PDF export with asset prefetching - - `Services/MapSnapshotService` - Generates static map images via MKMapSnapshotter - - `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 + - `PDFGenerator` — PDF trip itineraries with maps, photos, attractions + - `Sharing/` — Social share cards (progress, trips, achievements) + - `Services/` — MapSnapshotService, RemoteImageService, POISearchService, PDFAssetPrefetcher -### 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 │ │ SwiftData │ │ CloudKit │ -│ (App Bundle) │ │ (Local Store) │ │ (Remote Sync) │ -└────────┬─────────┘ └────────▲─────────┘ └────────┬─────────┘ - │ │ │ - │ Bootstrap │ Read │ Background - │ (first launch) │ │ Sync - ▼ │ ▼ -┌─────────────────────────────────┴────────────────────────────────────┐ -│ AppDataProvider.shared │ -│ (Single Source of Truth) │ -└─────────────────────────────────┬────────────────────────────────────┘ - │ - ▼ - All Features, ViewModels, Services +Bundled JSON → BootstrapService → SwiftData (CanonicalStadium/Team/Game) + ↑ +CloudKit ─── CanonicalSyncService ──────┘ + ↓ + AppDataProvider.shared (in-memory cache) + ↓ + 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() +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 2. **Configure**: `AppDataProvider.shared.configure(with: context)` 3. **Load**: `AppDataProvider.shared.loadInitialData()` reads SwiftData into memory 4. **App usable immediately** with local data 5. **Background sync**: `CanonicalSyncService.syncAll()` fetches CloudKit → updates SwiftData (non-blocking) 6. **Reload**: After sync completes, `loadInitialData()` refreshes in-memory cache - -**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 +7. **Analytics init**: `AnalyticsManager` initialized, super properties set **Canonical Data Models** (in SwiftData, synced from CloudKit): - `CanonicalStadium` → `Stadium` (domain) - `CanonicalTeam` → `Team` (domain) - `CanonicalGame` → `Game` (domain) -- `LeagueStructureModel` -- `TeamAlias`, `StadiumAlias` +- `LeagueStructureModel`, `TeamAlias`, `StadiumAlias` **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 -// ✅ CORRECT - Use AppDataProvider -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 allGames = try await AppDataProvider.shared.allGames(for: sports) +@Suite("TripPlanningEngine") +struct TripPlanningEngineTests { -// ❌ WRONG - Never access CloudKit directly for reads -let stadiums = try await CloudKitService.shared.fetchStadiums() // NO! + @Test("planningMode: dateRange is valid mode") + func planningMode_dateRange() { + // ... + #expect(prefs.planningMode == .dateRange) + } -// ❌ WRONG - Never fetch canonical data from SwiftData directly -let descriptor = FetchDescriptor() -let stadiums = try context.fetch(descriptor) // NO! (except in DataProvider/Sync/Bootstrap) + @Test("DrivingConstraints: clamps negative drivers to 1") + func drivingConstraints_clampsNegativeDrivers() { + // ... + } +} ``` -**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 (e.g., `StadiumVisit`) - Not canonical data +Pattern: `func {component}_{behavior}()` with descriptive `@Test("...")` display name. -### Key Data Flow +Example: `func drivingConstraints_maxDailyHours()`, `func planningMode_followTeam()` -``` -TripCreationView → TripCreationViewModel → PlanningRequest - → TripPlanningEngine (ScheduleMatcher + RouteOptimizer + TripScorer) - → PlanningResult → Trip → TripDetailView → SavedTrip (persist) +### Mocking Strategy + +- **Mock services**: `SportsTimeTests/Helpers/MockServices.swift` provides `MockDataProvider`, `MockLocationService`, `MockRouteService` +- **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) -- 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.88oakapps.SportsTime` -- `PDFGenerator` and `ExportService` are `@MainActor final class` (not actors) because they access MainActor-isolated UI properties and use UIKit drawing +When fixing a bug: +1. Write a regression test that reproduces the bug BEFORE fixing it +2. Include edge cases — test boundary conditions, nil/empty inputs, related scenarios +3. Confirm all tests pass after the fix +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/`): -- `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]`. +`TripPreferences` is the input to the planning engine. Key constraints: -**Self-hosted backend:** `https://analytics.88oakapps.com` -**API key:** Set in `AnalyticsManager.apiKey` (replace placeholder before shipping) +| Field | Type | Default | Validation | +|-------|------|---------|------------| +| `planningMode` | `PlanningMode` | `.dateRange` | Required. Determines which scenario planner runs | +| `sports` | `Set` | `[]` | 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` | 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:** - Event capture + autocapture -- Session replay (`screenshotMode` for SwiftUI, text inputs masked) +- Session replay (screenshotMode for SwiftUI, text inputs masked) - Network telemetry capture - Super properties (app version, device model, OS, pro status, selected sports) - Privacy opt-out toggle in Settings (persisted via UserDefaults `"analyticsOptedOut"`) @@ -165,170 +531,110 @@ case myNewEvent(param: String) AnalyticsManager.shared.track(.myNewEvent(param: "value")) ``` -**Correct Usage:** -```swift -// ✅ CORRECT - Use AnalyticsManager -AnalyticsManager.shared.track(.tripSaved(tripId: id, stopCount: 3, gameCount: 5)) -AnalyticsManager.shared.trackScreen("TripDetail") +## Environment Configuration -// ❌ WRONG - Never call PostHog SDK directly -PostHogSDK.shared.capture("trip_saved") // NO! -``` +- **CloudKit container**: `iCloud.com.88oakapps.SportsTime` (single container, no dev/prod split) +- **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. **Components** (`Core/Theme/`): -- `ThemedBackground` (ViewModifiers.swift) - Conditionally shows static gradient or animated background -- `AnimatedSportsBackground` (AnimatedBackground.swift) - Floating sports icons with route lines -- `DesignStyleManager.shared.animationsEnabled` - Toggle controlled in Settings +- `ThemedBackground` (ViewModifiers) — Conditionally shows static gradient or animated background +- `AnimatedSportsBackground` — Floating sports icons with route lines +- `DesignStyleManager.shared.animationsEnabled` — Toggle controlled in Settings -**How it works:** ```swift // All views apply this modifier - animation state is automatic .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 ### 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 +- `detectConflicts(for: ItineraryDay)` — Checks if multiple stops have games on the same calendar day +- **RouteOptionsCard**: Expandable card for conflicting routes (orange border, branch icon) +- **DayCard**: Non-conflict single-route display +- Supports itinerary reordering, custom item addition, travel day overrides ## 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 -The `docs/` directory contains project documentation: -- `MARKET_RESEARCH.md` - Competitive analysis and feature recommendations based on sports travel app market research (January 2026) - -## 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 -``` +- `docs/MARKET_RESEARCH.md` — Competitive analysis and feature recommendations +- `ARCHITECTURE.md` — High-level architecture overview +- `PROJECT_STATE.md` — Current project state and progress ## Future Phases See `docs/MARKET_RESEARCH.md` for full competitive analysis and feature prioritization. ### 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 +- Natural language trip planning via Apple Foundation Models (iOS 26+) +- On-device intelligence, privacy-preserving +- `@Generable` for structured output parsing ### Phase 3: Stadium Bucket List - -**Progress Tracking** -- Visual map showing visited vs. remaining stadiums per league -- 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) +- Visual map of visited vs remaining stadiums +- Digital passport/stamps, achievement badges +- Shareable progress cards ### Phase 4: Group Trip Coordination - -**Collaborative Planning** -- 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 +- Collaborative planning, polling/voting (partially implemented via `PollService`) +- Expense splitting, shared itinerary with real-time sync ### Phase 5: Fan Community - -**Social Features** -- Stadium tips from locals (best food, parking, pre-game bars) -- Fan meetup coordination for away games +- Stadium tips from locals, fan meetup coordination - Trip reviews and ratings -- Discussion forums for specific stadiums - -**Competitor**: Fantrip (fan-to-fan stays and local tips) - -## User Instruction -