Integrate self-hosted PostHog (SPM) with AnalyticsManager singleton wrapping all SDK calls. Adds ~40 type-safe events covering trip planning, schedule, progress, IAP, settings, polls, export, and share flows. Includes session replay, autocapture, network telemetry, privacy opt-out toggle in Settings, and super properties (app version, device, pro status, selected sports). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
335 lines
15 KiB
Markdown
335 lines
15 KiB
Markdown
# CLAUDE.md
|
|
|
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
|
|
## Build & Run Commands
|
|
|
|
```bash
|
|
# Build the iOS app
|
|
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build
|
|
|
|
# Run tests
|
|
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' test
|
|
|
|
# Run specific test suite
|
|
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/TripPlanningEngineTests test
|
|
|
|
# Run a single test
|
|
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/TestClassName/testMethodName test
|
|
|
|
# Data scraping (Python)
|
|
cd Scripts && pip install -r requirements.txt
|
|
python scrape_schedules.py --sport all --season 2026
|
|
```
|
|
|
|
## Architecture Overview
|
|
|
|
This is an iOS app for planning multi-stop sports road trips. It uses **Clean MVVM** with feature-based modules.
|
|
|
|
### Three-Layer Architecture
|
|
|
|
1. **Presentation Layer** (`Features/`): SwiftUI Views + @Observable ViewModels organized by feature (Home, Trip, Schedule, Settings)
|
|
|
|
2. **Domain Layer** (`Planning/`): Trip planning logic
|
|
- `TripPlanningEngine` - Main orchestrator, 7-step algorithm
|
|
- `RouteOptimizer` - TSP solver (exact for <8 stops, heuristic otherwise)
|
|
- `ScheduleMatcher` - Finds games along route corridor
|
|
- `TripScorer` - Multi-factor scoring (game quality, route efficiency, leisure balance)
|
|
|
|
3. **Data Layer** (`Core/`):
|
|
- `Models/Domain/` - Pure Swift structs (Trip, Game, Stadium, Team)
|
|
- `Models/CloudKit/` - CKRecord wrappers for public database
|
|
- `Models/Local/` - SwiftData models for local persistence (SavedTrip, UserPreferences)
|
|
- `Services/` - CloudKitService (schedules), LocationService (geocoding/routing)
|
|
|
|
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
|
|
|
|
### Data Architecture (Offline-First)
|
|
|
|
**CRITICAL: `AppDataProvider.shared` is the ONLY source of truth for canonical data.**
|
|
|
|
All code that reads stadiums, teams, games, or league structure MUST use `AppDataProvider.shared`. Never access CloudKit or SwiftData directly for this 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
|
|
```
|
|
|
|
**App 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
|
|
|
|
**Canonical Data Models** (in SwiftData, synced from CloudKit):
|
|
- `CanonicalStadium` → `Stadium` (domain)
|
|
- `CanonicalTeam` → `Team` (domain)
|
|
- `CanonicalGame` → `Game` (domain)
|
|
- `LeagueStructureModel`
|
|
- `TeamAlias`, `StadiumAlias`
|
|
|
|
**User Data Models** (local only, not synced):
|
|
- `SavedTrip`, `StadiumVisit`, `UserPreferences`, `Achievement`
|
|
|
|
**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)
|
|
|
|
// ❌ WRONG - Never access CloudKit directly for reads
|
|
let stadiums = try await CloudKitService.shared.fetchStadiums() // NO!
|
|
|
|
// ❌ WRONG - Never fetch canonical data from SwiftData directly
|
|
let descriptor = FetchDescriptor<CanonicalStadium>()
|
|
let stadiums = try context.fetch(descriptor) // NO! (except in DataProvider/Sync/Bootstrap)
|
|
```
|
|
|
|
**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
|
|
|
|
### Key Data Flow
|
|
|
|
```
|
|
TripCreationView → TripCreationViewModel → PlanningRequest
|
|
→ TripPlanningEngine (ScheduleMatcher + RouteOptimizer + TripScorer)
|
|
→ PlanningResult → Trip → TripDetailView → SavedTrip (persist)
|
|
```
|
|
|
|
## Important Patterns
|
|
|
|
- ViewModels use `@Observable` (not ObservableObject)
|
|
- All planning engine components are `actor` types for thread safety
|
|
- Domain models are pure Codable structs; SwiftData models wrap them via encoded `Data` fields
|
|
- CloudKit container ID: `iCloud.com.88oakapps.SportsTime`
|
|
- `PDFGenerator` and `ExportService` are `@MainActor final class` (not actors) because they access MainActor-isolated UI properties and use UIKit drawing
|
|
|
|
### Analytics (PostHog)
|
|
|
|
All analytics go through `AnalyticsManager.shared` — never call PostHog SDK directly.
|
|
|
|
**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]`.
|
|
|
|
**Self-hosted backend:** `https://analytics.88oakapps.com`
|
|
**API key:** Set in `AnalyticsManager.apiKey` (replace placeholder before shipping)
|
|
|
|
**Features enabled:**
|
|
- Event capture + autocapture
|
|
- 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"`)
|
|
|
|
**Adding new analytics:**
|
|
```swift
|
|
// 1. Add case to AnalyticsEvent enum
|
|
case myNewEvent(param: String)
|
|
|
|
// 2. Add name and properties in the computed properties
|
|
// 3. Call from anywhere:
|
|
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")
|
|
|
|
// ❌ WRONG - Never call PostHog SDK directly
|
|
PostHogSDK.shared.capture("trip_saved") // NO!
|
|
```
|
|
|
|
**Initialization:** Called during app bootstrap in `SportsTimeApp.performBootstrap()` (Step 7). Super properties refreshed on `.active` scene phase. Flushed on `.background`.
|
|
|
|
### 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
|
|
|
|
**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
|
|
|
|
## 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.
|
|
|
|
## 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
|
|
```
|
|
|
|
## 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
|
|
|
|
### 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)
|
|
|
|
### 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
|
|
|
|
### Phase 5: Fan Community
|
|
|
|
**Social Features**
|
|
- Stadium tips from locals (best food, parking, pre-game bars)
|
|
- Fan meetup coordination for away games
|
|
- Trip reviews and ratings
|
|
- Discussion forums for specific stadiums
|
|
|
|
**Competitor**: Fantrip (fan-to-fan stays and local tips)
|
|
|
|
## User Instruction
|
|
|