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>
This commit is contained in:
Trey t
2026-01-08 20:20:03 -06:00
parent 2281440bf8
commit 92d808caf5
55 changed files with 14348 additions and 61 deletions

View File

@@ -0,0 +1,841 @@
# 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 |