# 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`

```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`

```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`

```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`

```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`

```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`

```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`

```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"

```swift
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`

```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`

```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`

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

```swift
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`

```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).

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

### Tier 2: Free Sports APIs

**File**: `SportsTime/Core/Services/FreeScoreAPI.swift`

```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`

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

```swift
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

```swift
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

```swift
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`

```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**:

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

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

---

## Sharing & Export

**File**: `SportsTime/Export/Services/ProgressCardGenerator.swift`

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

```swift
// 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 |
