- Switch Release config to manual signing (Apple Distribution, SportsTime Dist profile) - Wrap SyncStatusMonitor.syncFailed call in #if DEBUG to fix Release compilation - Add deploy instructions to CLAUDE.md Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
33 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
Deploy to App Store Connect
Use the /deploy command (e.g., /deploy main) to archive and upload to TestFlight. If /deploy is unavailable, follow these steps manually:
# 1. Source secrets
source ~/Desktop/code/tony-scripts/tony.env
# 2. Checkout and pull
cd SportsTime
git fetch origin && git checkout <branch> && git pull origin <branch>
# 3. Unlock keychain for headless signing
security unlock-keychain -p "$KEYCHAIN_PASSWORD" ~/Library/Keychains/login.keychain-db
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" ~/Library/Keychains/login.keychain-db
# 4. Archive (do NOT override build number — App Store Connect auto-increments)
xcodebuild archive \
-project SportsTime.xcodeproj \
-scheme SportsTime \
-archivePath ~/reports/SportsTime.xcarchive \
-destination 'generic/platform=iOS' \
-allowProvisioningUpdates
# 6. Export and upload directly (auto-increments build number)
xcodebuild -exportArchive \
-archivePath ~/reports/SportsTime.xcarchive \
-exportPath ~/reports/export \
-exportOptionsPlist ~/Desktop/code/tony-scripts/ExportOptions-SportsTime-Upload.plist \
-allowProvisioningUpdates \
-authenticationKeyPath ~/.appstoreconnect/private_keys/AuthKey_${ASC_KEY_ID}.p8 \
-authenticationKeyID "$ASC_KEY_ID" \
-authenticationKeyIssuerID "$ASC_ISSUER_ID"
# 8. Clean up
rm -rf ~/reports/SportsTime.xcarchive ~/reports/export
Always confirm with the user before deploying. Report version, build number, and commit before starting the archive.
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
-
Presentation Layer (
Features/): SwiftUI Views +@ObservableViewModels organized by featureHome/— Main tab, saved trips list, suggested tripsTrip/— Trip wizard, trip detail, itinerary editingSchedule/— Game schedule browserProgress/— Stadium visit tracking, achievements, photo importPolls/— Trip poll creation, votingSettings/— App settings, debug toolsPaywall/— Pro subscription paywall
-
Domain Layer (
Planning/): Trip planning logicTripPlanningEngine— Main orchestrator, scenario dispatchScenarioAPlanner— Date range only, finds games in rangeScenarioBPlanner— Selected games + date rangeScenarioCPlanner— Start/end locations, games along routeScenarioDPlanner— Follow team scheduleScenarioEPlanner— Team-first, find trip windows across seasonGameDAGRouter— Game dependency graph routingRouteFilters— Geographic and constraint filteringTravelEstimator— Driving time/distance estimationItineraryBuilder— Constructs final itinerary from route candidates
-
Data Layer (
Core/):Models/Domain/— Pure Swift structs (Trip, Game, Stadium, Team, TripPreferences, TripPoll)Models/CloudKit/— CKRecord wrappers for public databaseModels/Local/— SwiftData models (SavedTrip, StadiumProgress, CanonicalModels, LocalPoll)Services/— CloudKitService, LocationService, DataProvider, AchievementEngine, PollService, BootstrapService, CanonicalSyncService, EVChargingService, FreeScoreAPI, GameMatcher, VisitPhotoService, etc.
-
Export Layer (
Export/):PDFGenerator— PDF trip itineraries with maps, photos, attractionsSharing/— 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 providerCanonicalSyncService.swift— CloudKit → SwiftData syncBootstrapService.swift— Initial data populationStadiumIdentityService.swift— Specialized identity resolution for stadium renames- User data (
StadiumVisit,SavedTrip,LocalPoll) — Not canonical data; read via@QueryorFetchDescriptor
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(notObservableObject). Exception:AppDataProviderusesObservableObjectfor@Published+ Combine. - All new screens MUST apply
.themedBackground()modifier. NEVER implement custom background gradients. - NEVER hardcode colors — use
Themeconstants fromCore/Theme/. - Planning engine components MUST be
final class. They are NOT actors. - Domain models MUST be pure
Codablestructs. SwiftData models wrap them via encodedDatafields. - ALWAYS track analytics events for user actions (saves, deletes, navigation). Add cases to
AnalyticsEventenum. - 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.
PDFGeneratorandExportServiceMUST 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 withModelContextat startup. - Planning Engine:
final class(NOT actors). Planning runs on caller's context (typically from aTaskin ViewModel). - StoreManager:
@Observable @MainActor final class. StoreKit 2 transaction listening runs as a backgroundTask. - CloudKit sync:
CanonicalSyncService.syncAll()runs async, non-blocking. UsesSyncCancellationTokenfor cancellation. - Export:
PDFGeneratorandExportServiceare@MainActor final classfor UIKit drawing access. - Background refresh:
BackgroundSyncManagerusesBGAppRefreshTaskfor periodic CloudKit sync.
Swift 6 / Sendable Notes
CLLocationCoordinate2Dgets@retroactive Codable, @retroactive Hashable, @retroactive Equatableconformance inTripPreferences.swift.- When capturing
varinasync let, create immutable copies first to avoid Swift 6 warnings. CloudKitContainerConfig.identifierandmakeContainer()arenonisolated staticfor 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
- Bootstrap (first launch only):
BootstrapServiceloads bundled JSON → SwiftData - Configure:
AppDataProvider.shared.configure(with: context) - Load:
AppDataProvider.shared.loadInitialData()reads SwiftData into memory - App usable immediately with local data
- Background sync:
CanonicalSyncService.syncAll()fetches CloudKit → updates SwiftData (non-blocking) - Reload: After sync completes,
loadInitialData()refreshes in-memory cache - Analytics init:
AnalyticsManagerinitialized, super properties set
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,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.swiftor{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.swiftprovidesMockDataProvider,MockLocationService,MockRouteService - Test fixtures:
SportsTimeTests/Helpers/TestFixtures.swiftprovidesTestFixtures.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:
- Write a regression test that reproduces the bug BEFORE fixing it
- Include edge cases — test boundary conditions, nil/empty inputs, related scenarios
- Confirm all tests pass after the fix
- 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: Needssports, date range.gameFirst: NeedsmustSeeGameIds(selected games).locations: NeedsstartLocation+endLocation(with resolved coordinates).followTeam: NeedsfollowTeamId.teamFirst: NeedsselectedTeamIds
Engine failure modes (returns PlanningFailure):
noGamesInRange— No games found within date rangenoValidRoutes— No valid routes could be constructedmissingDateRange/missingLocations/missingTeamSelection— Required fields missingdateRangeViolation— Selected games fall outside date rangedrivingExceedsLimit— Driving time exceeds daily limitrepeatCityViolation— Route visits same city on multiple days (whenallowRepeatCities = false)geographicBacktracking— Route requires excessive backtracking
Platform Gotchas
iOS 26 Deprecated APIs:
CLGeocoder→ UseMKLocalSearchwith.addressresult type insteadMKPlacemarkproperties (locality, administrativeArea, etc.) → Still work but deprecatedMKMapItem.locationis non-optional in iOS 26
Framework Gotchas
- SwiftData context threading:
ModelContextis notSendable. 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:
debugProOverridedefaults totruein DEBUG builds, bypassing real subscription checks. - ETag sensitivity:
AppDataProvideruses 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
dateTimein UTC. Display uses stadium's local timezone. Timezone edge cases near midnight can show game on wrong calendar day. - Stadium renames:
StadiumIdentityServiceresolves old stadium names to current canonical IDs viaStadiumAlias. - Historical scores:
FreeScoreAPIscrapes historical game scores. Rate-limited to avoid bans.
Common Developer Mistakes
- Querying
CanonicalStadiumdirectly from SwiftData instead of usingAppDataProvider.shared— causes stale data and bypasses the canonical ID lookup - Calling
PostHogSDK.shared.capture()directly instead ofAnalyticsManager.shared.track()— bypasses opt-out, super properties, and type-safe event definitions - Forgetting
.themedBackground()on new screens — breaks visual consistency - Using
ObservableObjectfor new ViewModels instead of@Observable— inconsistent with codebase pattern - Creating planning engine components as
actorinstead offinal class— they use@MainActorisolation 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-iosv3.41.0) - Manager:
AnalyticsManager.shared(Core/Analytics/AnalyticsManager.swift) —@MainActorsingleton - Events:
AnalyticsEventenum (Core/Analytics/AnalyticsEvent.swift) — ~40 type-safe event cases withnameandproperties - 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.debugProOverridedefaults totrue(bypasses subscription check). Toggle in Settings debug section. - Analytics: Self-hosted PostHog at
https://analytics.88oakapps.com. API key set inAnalyticsManager.apiKey. - Data scraping: Python scripts in
Scripts/withrequirements.txt. SeeScripts/DATA_SOURCES.mdfor source URLs and rate limits.
Directory Conventions
When adding new files:
- New features:
Features/{FeatureName}/Views/andFeatures/{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 backgroundAnimatedSportsBackground— Floating sports icons with route linesDesignStyleManager.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 recommendationsARCHITECTURE.md— High-level architecture overviewPROJECT_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
@Generablefor 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