Files
Sportstime/CLAUDE.md
2026-02-18 13:24:46 -06:00

31 KiB

SportsTime

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 the iOS app
xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build

# 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/TripPlanningEngineTests/planningMode_dateRange test

# Run UI tests only
xcodebuild test-without-building -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -parallel-testing-enabled NO -only-testing:SportsTimeUITests

# Data scraping (Python)
cd Scripts && pip install -r requirements.txt
python scrape_schedules.py --sport all --season 2026

Architecture Overview

  • 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

Layers

  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, 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, 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 — PDF trip itineraries with maps, photos, attractions
    • Sharing/ — Social share cards (progress, trips, achievements)
    • Services/ — MapSnapshotService, RemoteImageService, POISearchService, PDFAssetPrefetcher

Key Components

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

Data Flow

Read (canonical data):

Bundled JSON → BootstrapService → SwiftData (CanonicalStadium/Team/Game)
                                        ↑
CloudKit ─── CanonicalSyncService ──────┘
                                        ↓
                              AppDataProvider.shared (in-memory cache)
                                        ↓
                              All Features, ViewModels, Services

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

// ✅ 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

// ❌ 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

// ✅ 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

// ✅ 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

// ✅ 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

// ✅ 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

// ❌ 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
  7. Analytics init: AnalyticsManager initialized, super properties set

Canonical Data Models (in SwiftData, synced from CloudKit):

  • CanonicalStadiumStadium (domain)
  • CanonicalTeamTeam (domain)
  • CanonicalGameGame (domain)
  • LeagueStructureModel, TeamAlias, StadiumAlias

User Data Models (local only, not synced):

  • 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/ is active and uses XCTest + page-object patterns
  • UI authoring guide: XCUITest-Authoring.md
  • UI suite template: XCUITestSuiteTemplate.swift
  • UI request template: uiTestPrompt.md

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:

@Suite("TripPlanningEngine")
struct TripPlanningEngineTests {

    @Test("planningMode: dateRange is valid mode")
    func planningMode_dateRange() {
        // ...
        #expect(prefs.planningMode == .dateRange)
    }

    @Test("DrivingConstraints: clamps negative drivers to 1")
    func drivingConstraints_clampsNegativeDrivers() {
        // ...
    }
}

Pattern: func {component}_{behavior}() with descriptive @Test("...") display name.

Example: func drivingConstraints_maxDailyHours(), func planningMode_followTeam()

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
// 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")

Bug Fix Protocol

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

Known Edge Cases & Gotchas

Planning Engine Input Validation

TripPreferences is the input to the planning engine. Key constraints:

Field Type Default Validation
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:

  • 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:

// 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"))

Environment Configuration

  • 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.

Directory Conventions

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) — Conditionally shows static gradient or animated background
  • AnimatedSportsBackground — Floating sports icons with route lines
  • DesignStyleManager.shared.animationsEnabled — Toggle controlled in Settings
// All views apply this modifier - animation state is automatic
.themedBackground()

Key View Components

TripDetailView (Features/Trip/Views/TripDetailView.swift)

Displays trip itinerary with conflict detection for same-day games in different cities.

  • 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 for cross-validation. See Scripts/DATA_SOURCES.md for source URLs and rate limits.

Documentation

  • 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 via Apple Foundation Models (iOS 26+)
  • On-device intelligence, privacy-preserving
  • @Generable for structured output parsing

Phase 3: Stadium Bucket List

  • Visual map of visited vs remaining stadiums
  • Digital passport/stamps, achievement badges
  • Shareable progress cards

Phase 4: Group Trip Coordination

  • Collaborative planning, polling/voting (partially implemented via PollService)
  • Expense splitting, shared itinerary with real-time sync

Phase 5: Fan Community

  • Stadium tips from locals, fan meetup coordination
  • Trip reviews and ratings