Files
Sportstime/docs/STADIUM_PROGRESS_SPEC.md
Trey t 92d808caf5 Add Stadium Progress system and themed loading spinners
Stadium Progress & Achievements:
- Add StadiumVisit and Achievement SwiftData models
- Create Progress tab with interactive map view
- Implement photo-based visit import with GPS/date matching
- Add achievement badges (count-based, regional, journey)
- Create shareable progress cards for social media
- Add canonical data infrastructure (stadium identities, team aliases)
- Implement score resolution from free APIs (MLB, NBA, NHL stats)

UI Improvements:
- Add ThemedSpinner and ThemedSpinnerCompact components
- Replace all ProgressView() with themed spinners throughout app
- Fix sport selection state not persisting when navigating away

Bug Fixes:
- Fix Coast to Coast trips showing only 1 city (validation issue)
- Fix stadium progress showing 0/0 (filtering issue)
- Remove "Stadium Quest" title from progress view

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 20:20:03 -06:00

27 KiB

Stadium Progress & Achievement System

Overview

Track stadium visits, visualize progress on a map, earn achievement badges, and share progress cards. Supports MLB, NBA, NHL with historical visit logging and photo attachments synced to iCloud.

User Requirements

  • Visual Progress Map: Interactive map showing visited/unvisited stadiums, filterable by league
  • Shareable Progress Cards: Social media export with stats and optional map snapshot
  • Achievement Badges: Count-based, regional (division), journey-based, league completion
  • Visit Logging: Auto-fill from app games, manual/historical entry, stadium-only visits (tours)
  • Photo Attachments: Multiple photos per visit, iCloud sync for backup

Design Decisions

Decision Choice
Photo storage iCloud sync (CloudKit private database)
Historical games Manual entry fallback + stadium-only visits allowed
Achievement policy Recalculate & revoke when visits deleted

Data Models

SwiftData Models

File: SportsTime/Core/Models/Local/StadiumProgress.swift

@Model
final class StadiumVisit {
    @Attribute(.unique) var id: UUID
    var canonicalStadiumId: UUID       // Stable ID across renames
    var stadiumNameAtVisit: String     // Frozen at visit time
    var visitDate: Date
    var sport: String                  // Sport.rawValue
    var visitType: String              // "game" | "tour" | "other"

    // Game info (optional)
    var gameId: UUID?
    var homeTeamId: UUID?
    var awayTeamId: UUID?
    var homeTeamName: String?          // For display when team lookup fails
    var awayTeamName: String?
    var finalScore: String?            // "5-3" format
    var manualGameDescription: String? // User's description if game not found

    // Resolution tracking
    var scoreSource: String            // "app" | "api" | "scraped" | "user"
    var dataSource: String             // "automatic" | "partial_manual" | "fully_manual" | "user_corrected"
    var scoreResolutionPending: Bool   // true if background retry needed

    // User data
    var seatLocation: String?
    var notes: String?

    // Photos
    @Relationship(deleteRule: .cascade)
    var photoMetadata: [VisitPhotoMetadata]?

    // Photo import metadata (preserved for debugging/re-matching)
    var photoLatitude: Double?
    var photoLongitude: Double?
    var photoCaptureDate: Date?

    var createdAt: Date
    var source: String                 // "trip" | "manual" | "photo_import"
}

@Model
final class VisitPhotoMetadata {
    @Attribute(.unique) var id: UUID
    var visitId: UUID
    var cloudKitAssetId: String
    var thumbnailData: Data?           // 200x200 JPEG
    var caption: String?
    var orderIndex: Int
    var uploadStatus: String           // "pending" | "uploaded" | "failed"
}

@Model
final class Achievement {
    @Attribute(.unique) var id: UUID
    var achievementTypeId: String      // "mlb_all_30" | "nl_west"
    var sport: String?
    var earnedAt: Date
    var revokedAt: Date?               // Non-nil if visits deleted
    var visitIdsSnapshot: Data         // [UUID] that earned this
}

Domain Models

File: SportsTime/Core/Models/Domain/Division.swift

struct Division: Identifiable, Codable {
    let id: String              // "MLB_NL_WEST"
    let name: String            // "NL West"
    let conference: String      // "National League"
    let sport: Sport
    let teamIds: [UUID]
}

enum LeagueStructure {
    static let mlbDivisions: [Division]  // 6 divisions
    static let nbaDivisions: [Division]  // 6 divisions
    static let nhlDivisions: [Division]  // 4 divisions

    static func divisions(for sport: Sport) -> [Division]
    static func division(forTeam teamId: UUID) -> Division?
}

File: SportsTime/Core/Models/Domain/Progress.swift

struct LeagueProgress {
    let sport: Sport
    let totalStadiums: Int
    let visitedStadiums: Int
    let stadiumsVisited: [Stadium]
    let stadiumsRemaining: [Stadium]
    let completionPercentage: Double
    let divisionProgress: [DivisionProgress]
}

enum VisitType: String, Codable, CaseIterable {
    case game, tour, other
}

Stadium Identity Strategy

Problem: Stadiums rename (SBC Park → AT&T Park → Oracle Park). Same physical location should count as one visit.

Solution: Canonical stadium IDs stored in bundled JSON.

File: SportsTime/Core/Services/StadiumIdentityService.swift

actor StadiumIdentityService {
    static let shared = StadiumIdentityService()

    func canonicalId(for stadiumId: UUID) -> UUID
    func canonicalId(forName name: String) -> UUID?
    func isSameStadium(_ id1: UUID, _ id2: UUID) -> Bool
}

File: Resources/stadium_identities.json

[{
  "canonicalId": "...",
  "currentName": "Oracle Park",
  "allNames": ["Oracle Park", "AT&T Park", "SBC Park"],
  "stadiumUUIDs": ["uuid1", "uuid2"],
  "sport": "MLB",
  "openedYear": 2000,
  "closedYear": null
}]
Scenario Handling
Stadium renamed All names map to same canonicalId
Team relocated Old stadium gets closedYear, new is separate
Demolished stadium Still counts for historical visits
Shared stadium (Jets/Giants) Single canonicalId, multiple teamIds

Achievement System

File: SportsTime/Core/Models/Domain/AchievementDefinitions.swift

Achievement Types

Category Examples
Count "First Pitch" (1), "Double Digits" (10), "Veteran Fan" (20)
Division "NL West Champion", "AFC North Complete"
Conference "National League Complete"
League "Diamond Collector" (all 30 MLB)
Journey "Road Warrior" (5 in 7 days), "Triple Threat" (3 leagues)

Achievement Engine

File: SportsTime/Core/Services/AchievementEngine.swift

actor AchievementEngine {
    /// Full recalculation (call after visit deleted)
    func recalculateAllAchievements() async throws -> AchievementDelta

    /// Quick check after new visit
    func checkAchievementsForNewVisit(_ visit: StadiumVisit) async throws -> [AchievementDefinition]
}

Recalculation triggers:

  • Visit added → incremental check
  • Visit deleted → full recalculation (may revoke)
  • App update with new achievements → full recalculation

Canonical Game Identity

Problem: Scraped games, API games, and app games will disagree on IDs, team naming, and sometimes scores (corrections). Need a stable, derived key.

File: SportsTime/Core/Models/Domain/CanonicalGameKey.swift

/// Derived, stable key that prevents duplicates and handles score drift
struct CanonicalGameKey: Hashable, Codable {
    let sport: Sport
    let stadiumCanonicalId: UUID
    let gameDate: Date              // Normalized to local stadium date
    let homeTeamCanonicalId: UUID
}

/// Resolved game with source tracking
struct ResolvedGame {
    let canonicalKey: CanonicalGameKey
    let rawSourceId: String?        // Original ID from source
    let resolutionSource: ResolutionSource

    // Game data
    let homeTeamName: String
    let awayTeamName: String
    let homeScore: Int?
    let awayScore: Int?

    // Audit trail
    let resolvedAt: Date
    let sourceVersion: String?      // API version or scrape date
}

Key normalization rules:

  • gameDate normalized to stadium's local timezone midnight
  • stadiumCanonicalId from StadiumIdentityService
  • homeTeamCanonicalId from team alias mapping

Every resolution path (app data, API, scrape, user) must map to CanonicalGameKey.


Historical Game Resolution

File: SportsTime/Core/Services/HistoricalGameService.swift

Strategy (in order):

  1. Bundled indexes - Date → stadium → home team lookup (no scores)
  2. Lazy-fetch scores - From API/scrape on first access, cache forever
  3. Manual entry - User describes game, marked "user verified"
actor HistoricalGameService {
    func searchGames(query: HistoricalGameQuery) async throws -> [HistoricalGameResult]
    func resolveCanonicalKey(from result: HistoricalGameResult) -> CanonicalGameKey
}

struct HistoricalGameQuery {
    let sport: Sport
    let date: Date
    let stadiumCanonicalId: UUID?   // If known from photo
    let homeTeamName: String?
    let awayTeamName: String?
}

Bundled Data Strategy (Size-Conscious)

Problem: historical_games_mlb.json at ~5MB/sport will explode over time.

Solution: Bundle indexes only, lazy-fetch scores.

File: Resources/historical_game_index_mlb.json

{
  "2010-06-15": {
    "stadium-uuid-oracle-park": {
      "homeTeamId": "team-uuid-sfg",
      "awayTeamId": "team-uuid-lad"
    }
  }
}

Size: ~500KB/sport (dates + stadium + teams only)

Score fetching: On first lookup, fetch from API/scrape, cache in CachedGameScore forever.


Photo-Based Import Pipeline

Primary method for logging historical visits: Import photos from library, extract metadata, auto-resolve game.

Photo Metadata Extraction

File: SportsTime/Core/Services/PhotoMetadataExtractor.swift

struct PhotoMetadata {
    let captureDate: Date?
    let coordinates: CLLocationCoordinate2D?
    let hasValidLocation: Bool
    let hasValidDate: Bool
}

actor PhotoMetadataExtractor {
    /// Extract EXIF data from PHAsset
    func extractMetadata(from asset: PHAsset) async -> PhotoMetadata

    /// Extract from UIImage with ImageIO (fallback)
    func extractMetadata(from imageData: Data) -> PhotoMetadata
}

Extraction sources (in order):

  1. PHAsset.location and PHAsset.creationDate (preferred)
  2. EXIF via ImageIO: kCGImagePropertyGPSLatitude, kCGImagePropertyExifDateTimeOriginal
  3. File creation date (last resort, unreliable)

Stadium Proximity Matching

File: SportsTime/Core/Services/StadiumProximityMatcher.swift

struct StadiumMatch {
    let stadium: Stadium
    let distance: CLLocationDistance
    let confidence: MatchConfidence
}

enum MatchConfidence {
    case high      // < 500m from stadium center
    case medium    // 500m - 2km
    case low       // 2km - 5km
    case none      // > 5km or no coordinates
}

actor StadiumProximityMatcher {
    /// Find stadiums within radius of coordinates
    func findNearbyStadiums(
        coordinates: CLLocationCoordinate2D,
        radius: CLLocationDistance = 5000 // 5km default
    ) async -> [StadiumMatch]
}

Configurable parameters:

  • highConfidenceRadius: 500m (auto-select threshold)
  • searchRadius: 5km (maximum search distance)
  • dateToleranceDays: 1 (for doubleheaders, timezone issues)

Temporal Confidence

Problem: Photos taken hours before game, next morning, or during tailgating can still be valid.

enum TemporalConfidence {
    case exactDay       // Same local date as game
    case adjacentDay    // ±1 day (tailgating, next morning)
    case outOfRange     // >1 day difference
}

struct PhotoMatchConfidence {
    let spatial: MatchConfidence      // Distance-based
    let temporal: TemporalConfidence  // Time-based
    let combined: CombinedConfidence  // Final score
}

enum CombinedConfidence {
    case autoSelect     // High spatial + exactDay → auto-select
    case userConfirm    // Medium spatial OR adjacentDay → user confirms
    case manualOnly     // Low spatial OR outOfRange → manual entry
}

Combination rules:

Spatial Temporal Result
high exactDay autoSelect
high adjacentDay userConfirm
medium exactDay userConfirm
medium adjacentDay userConfirm
low * manualOnly
* outOfRange manualOnly

Deterministic Game Matching

File: SportsTime/Core/Services/GameMatcher.swift

enum GameMatchResult {
    case singleMatch(HistoricalGameResult)           // Auto-select
    case multipleMatches([HistoricalGameResult])     // User selects
    case noMatches(reason: NoMatchReason)            // Manual entry
}

enum NoMatchReason {
    case noStadiumNearby
    case noGamesOnDate
    case metadataMissing
}

actor GameMatcher {
    /// Match photo metadata to a game
    func matchGame(
        metadata: PhotoMetadata,
        sport: Sport?
    ) async -> GameMatchResult
}

Resolution rules (deterministic, never guess):

Scenario Action
1 game at 1 stadium on date Auto-select
Multiple games same stadium (doubleheader) User selects
Multiple stadiums nearby User selects stadium first
0 games found Manual entry allowed
Missing GPS Manual entry required
Missing date Manual entry required

Score Resolution Strategy

Layered approach using FREE data sources only.

Tier 1: App Schedule Data

Check if game exists in app's schedule database (current season + cached historical).

// In HistoricalGameService
func resolveFromAppData(query: HistoricalGameQuery) async -> HistoricalGameResult?

Tier 2: Free Sports APIs

File: SportsTime/Core/Services/FreeScoreAPI.swift

enum ProviderReliability {
    case official    // MLB Stats, NHL Stats - stable, documented
    case unofficial  // ESPN API - works but may break
    case scraped     // Sports-Reference - HTML parsing, fragile
}

protocol ScoreAPIProvider {
    var name: String { get }
    var supportedSports: Set<Sport> { get }
    var rateLimit: TimeInterval { get }
    var reliability: ProviderReliability { get }

    func fetchGame(query: HistoricalGameQuery) async throws -> ResolvedGame?
}

actor FreeScoreAPI {
    private let providers: [ScoreAPIProvider]
    private var disabledProviders: [String: Date] = [:]  // provider → disabled until
    private var failureCounts: [String: Int] = [:]

    /// Try each provider in order: official > unofficial > scraped
    func resolveScore(query: HistoricalGameQuery) async -> ScoreResolutionResult

    /// Auto-disable logic for unreliable providers
    private func recordFailure(for provider: ScoreAPIProvider)
    private func isDisabled(_ provider: ScoreAPIProvider) -> Bool
}

Provider failure handling:

  • Official providers: Retry on failure, never auto-disable
  • Unofficial providers: After 3 failures in 1 hour → disable for 24h
  • Scraped providers: After 2 failures in 1 hour → disable for 24h

Provider priority order (always prefer stability):

  1. Official APIs (MLB Stats, NHL Stats)
  2. Unofficial APIs (ESPN, NBA Stats)
  3. Scraped sources (Sports-Reference)

Free API providers (implement ScoreAPIProvider):

Provider Sports Rate Limit Reliability Notes
MLB Stats API MLB 10 req/sec official Documented, stable
NHL Stats API NHL 5 req/sec official Documented, stable
NBA Stats API NBA 2 req/sec unofficial Requires headers, may break
ESPN API All 1 req/sec unofficial Undocumented, good depth

Tier 3: Reference Site Scraping

File: SportsTime/Core/Services/ReferenceScrapingService.swift

actor ReferenceScrapingService {
    /// Scrape game data from sports-reference sites
    func scrapeGame(query: HistoricalGameQuery) async throws -> HistoricalGameResult?
}

Sources:

  • Baseball-Reference.com (MLB, 1876-present)
  • Basketball-Reference.com (NBA, 1946-present)
  • Hockey-Reference.com (NHL, 1917-present)
  • Pro-Football-Reference.com (NFL, 1920-present)

Scraping rules:

  • Cache aggressively: Historical scores never change
  • Rate limit: Max 1 request per 3 seconds per domain
  • Respect robots.txt: Check before scraping
  • User-Agent: Identify as SportsTime app

Tier 4: User Confirmation

If all automated resolution fails:

struct UserConfirmedGame {
    let homeTeam: String
    let awayTeam: String
    let finalScore: String?      // Optional - user may not remember
    let isUserConfirmed: Bool    // Always true for this tier
}

UI flow:

  1. Show "We couldn't find this game automatically"
  2. Ask for home team, away team (autocomplete from known teams)
  3. Optionally ask for score
  4. Mark entry as source = "user_confirmed"

Score Resolution Result

enum ScoreResolutionResult {
    case resolved(HistoricalGameResult, source: ResolutionSource)
    case pending                 // Background retry queued
    case requiresUserInput       // All tiers failed
}

enum ResolutionSource: String, Codable {
    case appData = "app"
    case freeAPI = "api"
    case scraped = "scraped"
    case userConfirmed = "user"
}

Photo Storage & Sync

File: SportsTime/Core/Services/VisitPhotoService.swift

  • Thumbnails: Stored locally as Data in SwiftData (fast loading)
  • Full images: CloudKit CKAsset in user's private database
  • Upload: Background task, retry on failure
  • Download: On-demand with local caching
actor VisitPhotoService {
    func addPhoto(to visit: StadiumVisit, image: UIImage, caption: String?) async throws -> VisitPhotoMetadata
    func fetchFullImage(for metadata: VisitPhotoMetadata) async throws -> UIImage
    func deletePhoto(_ metadata: VisitPhotoMetadata) async throws
}

Caching & Rate Limiting

File: SportsTime/Core/Services/ScoreResolutionCache.swift

@Model
final class CachedGameScore {
    @Attribute(.unique) var cacheKey: String  // "MLB_2010-06-15_SFG_LAD"
    var homeTeam: String
    var awayTeam: String
    var homeScore: Int
    var awayScore: Int
    var source: String
    var fetchedAt: Date
    var expiresAt: Date?  // nil = never expires (historical data)
}

actor ScoreResolutionCache {
    func getCached(query: HistoricalGameQuery) async -> HistoricalGameResult?
    func cache(result: HistoricalGameResult, query: HistoricalGameQuery) async
}

Cache policy:

  • Historical games (>30 days old): Cache forever
  • Recent games: Cache 24 hours (scores might update)
  • Failed lookups: Cache 7 days (avoid repeated failures)

Rate limiter:

actor RateLimiter {
    private var lastRequestTimes: [String: Date] = [:]

    func waitIfNeeded(for provider: String, interval: TimeInterval) async
}

Sharing & Export

File: SportsTime/Export/Services/ProgressCardGenerator.swift

@MainActor
final class ProgressCardGenerator {
    func generateCard(progress: LeagueProgress, options: ProgressCardOptions) async throws -> UIImage
    func generateProgressMap(visited: [Stadium], remaining: [Stadium]) async throws -> UIImage
}

Card contents:

  • League logo
  • Progress ring (X/30)
  • Stats row
  • Optional username
  • Mini map snapshot
  • App branding footer

Export size: 1080x1920 (Instagram story)


Files to Create

Core Models

  • SportsTime/Core/Models/Local/StadiumProgress.swift - SwiftData models
  • SportsTime/Core/Models/Domain/Progress.swift - Domain structs
  • SportsTime/Core/Models/Domain/Division.swift - League structure
  • SportsTime/Core/Models/Domain/AchievementDefinitions.swift - Badge registry
  • SportsTime/Core/Models/Domain/CanonicalGameKey.swift - Stable game identity + ResolvedGame

Services

  • SportsTime/Core/Services/StadiumIdentityService.swift - Canonical ID resolution
  • SportsTime/Core/Services/AchievementEngine.swift - Achievement computation
  • SportsTime/Core/Services/HistoricalGameService.swift - Historical lookup orchestrator
  • SportsTime/Core/Services/VisitPhotoService.swift - CloudKit photo sync

Photo Import Pipeline

  • SportsTime/Core/Services/PhotoMetadataExtractor.swift - EXIF extraction from PHAsset
  • SportsTime/Core/Services/StadiumProximityMatcher.swift - GPS-to-stadium matching
  • SportsTime/Core/Services/GameMatcher.swift - Deterministic game resolution

Score Resolution

  • SportsTime/Core/Services/FreeScoreAPI.swift - Multi-provider API facade
  • SportsTime/Core/Services/ScoreAPIProviders/ESPNProvider.swift - ESPN unofficial API
  • SportsTime/Core/Services/ScoreAPIProviders/MLBStatsProvider.swift - MLB Stats API
  • SportsTime/Core/Services/ScoreAPIProviders/NHLStatsProvider.swift - NHL Stats API
  • SportsTime/Core/Services/ScoreAPIProviders/NBAStatsProvider.swift - NBA Stats API
  • SportsTime/Core/Services/ReferenceScrapingService.swift - Sports-Reference fallback
  • SportsTime/Core/Services/ScoreResolutionCache.swift - SwiftData cache for scores
  • SportsTime/Core/Services/RateLimiter.swift - Per-provider rate limiting

Features

  • SportsTime/Features/Progress/Views/ProgressTabView.swift - Main tab
  • SportsTime/Features/Progress/Views/ProgressMapView.swift - Interactive map
  • SportsTime/Features/Progress/Views/StadiumVisitSheet.swift - Log visit (manual entry)
  • SportsTime/Features/Progress/Views/PhotoImportView.swift - Photo picker + metadata extraction
  • SportsTime/Features/Progress/Views/GameMatchConfirmationView.swift - Disambiguate multiple matches
  • SportsTime/Features/Progress/Views/VisitDetailView.swift - View/edit visit
  • SportsTime/Features/Progress/Views/AchievementsListView.swift - Badge gallery
  • SportsTime/Features/Progress/Views/ProgressShareView.swift - Share preview
  • SportsTime/Features/Progress/ViewModels/ProgressViewModel.swift - Main state
  • SportsTime/Features/Progress/ViewModels/PhotoImportViewModel.swift - Photo import orchestration

Export

  • SportsTime/Export/Services/ProgressCardGenerator.swift - Shareable cards

Resources

  • Resources/stadium_identities.json - Canonical stadium mapping
  • Resources/league_structure.json - Division/conference data
  • Resources/historical_game_index_mlb.json - Game index (date → stadium → teams, no scores)
  • Resources/historical_game_index_nba.json - Game index (~500KB)
  • Resources/historical_game_index_nhl.json - Game index (~500KB)
  • Resources/team_aliases.json - Team name normalization (old names → canonical IDs)

Files to Modify

  • SportsTime/SportsTimeApp.swift - Add new models to SwiftData schema
  • SportsTime/Features/Home/Views/HomeView.swift - Add Progress tab

Implementation Phases

Phase 1: Data Foundation

  1. Create SwiftData models (StadiumVisit, Achievement, VisitPhotoMetadata, CachedGameScore)
  2. Create Division.swift with LeagueStructure
  3. Build StadiumIdentityService with bundled JSON
  4. Create CanonicalGameKey and ResolvedGame
  5. Create RateLimiter actor
  6. Update SportsTimeApp.swift schema

Phase 2: Core Progress UI

  1. Create ProgressViewModel
  2. Build ProgressTabView with league selector
  3. Implement ProgressMapView with MKMapView
  4. Add Progress tab to HomeView

Phase 3: Manual Visit Logging

  1. Create StadiumVisitSheet for manual entry
  2. Implement auto-fill from trip games
  3. Build VisitDetailView for viewing/editing

Phase 4: Photo Import Pipeline

  1. Build PhotoMetadataExtractor (EXIF from PHAsset)
  2. Create StadiumProximityMatcher (GPS → stadium)
  3. Implement GameMatcher (deterministic rules + confidence scoring)
  4. Build PhotoImportView with picker
  5. Create GameMatchConfirmationView for disambiguation
  6. Integrate into ProgressViewModel

Phase 5: Score Resolution

  1. Create ScoreAPIProvider protocol with reliability
  2. Implement MLB Stats API provider (official)
  3. Implement NHL Stats API provider (official)
  4. Implement NBA Stats API provider (unofficial)
  5. Implement ESPN API provider (unofficial, fallback)
  6. Build ReferenceScrapingService (Tier 3, scraped)
  7. Create ScoreResolutionCache
  8. Wire up FreeScoreAPI orchestrator with auto-disable

Phase 6: Achievements

  1. Create AchievementDefinitions registry
  2. Build AchievementEngine with computation logic
  3. Create achievement UI components
  4. Wire up achievement earned notifications

Phase 7: Photos & Sharing

  1. Implement VisitPhotoService with CloudKit
  2. Build photo gallery UI
  3. Create ProgressCardGenerator
  4. Implement share sheet integration

Failure Modes & Recovery

Explicit handling for all failure scenarios — no silent failures.

Photo Import Failures

Failure Detection Recovery
Missing GPS in photo PhotoMetadata.hasValidLocation == false Show "Location not found" → manual stadium selection
Missing date in photo PhotoMetadata.hasValidDate == false Show "Date not found" → manual date picker
Photo library access denied PHPhotoLibrary.authorizationStatus() Show settings deep link, explain why needed
PHAsset fetch fails PHImageManager error Show error, allow retry or skip

Game Matching Failures

Failure Detection Recovery
No stadium within 5km StadiumProximityMatcher returns empty Show "No stadium found nearby" → manual stadium picker
No games on date GameMatcher returns .noMatches Show "No games found" → allow manual entry with team autocomplete
Ambiguous match (multiple games) GameMatcher returns .multipleMatches Show picker: "Which game?" with team matchups
Ambiguous stadium (multiple nearby) Multiple stadiums in radius Show picker: "Which stadium?" with distances

Score Resolution Failures

Failure Detection Recovery
All API tiers fail ScoreResolutionResult.requiresUserInput Show "Score not found" → optional manual score entry
Rate limited by provider HTTP 429 Queue for background retry, show "pending" state
Network offline URLError.notConnectedToInternet Cache partial visit, retry score on reconnect
Unofficial provider breaks 3 failures in 1 hour Auto-disable for 24h, use next tier
Scraped provider breaks 2 failures in 1 hour Auto-disable for 24h, use next tier

Data Integrity Failures

Failure Detection Recovery
CloudKit upload fails CKError Store locally with uploadStatus = "failed", retry queue
SwiftData save fails ModelContext error Show error, don't dismiss sheet, allow retry
Corrupt cached score JSON decode fails Delete cache entry, refetch
Duplicate visit detected Same stadium + date Warn user, allow anyway (doubleheader edge case)

User Data Marking

All user-provided data is explicitly marked:

// In StadiumVisit
var dataSource: DataSource

enum DataSource: String, Codable {
    case automatic      // All data from photo + API
    case partialManual  // Photo metadata + manual game selection
    case fullyManual    // User entered everything
    case userCorrected  // Was automatic, user edited
}

Edge Cases

Scenario Handling
Stadium renamed after visit canonicalStadiumId stable, stadiumNameAtVisit frozen
User visits same stadium twice Both logged, unique stadiums counted once
Visit without game (tour) visitType = "tour", no game fields required
Historical game not found Manual description, source = "user_confirmed"
Photo upload fails uploadStatus = "failed", retry on next launch
Achievement revoked revokedAt set, not shown in earned list
Team relocates mid-tracking Old stadium still counts, new is separate
Doubleheader same day Both games shown, user selects correct one
Photo from parking lot (1km away) Medium confidence, still matches if only stadium nearby
Multiple sports same stadium Filter by sport if provided, else show all
Timezone mismatch (night game shows wrong date) Use ±1 day tolerance in matching
User edits auto-resolved data Mark as dataSource = .userCorrected, preserve original
API returns different team name Fuzzy match via team_aliases.json
Score correction after caching Cache key includes source version, can refresh