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