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>
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:
gameDatenormalized to stadium's local timezone midnightstadiumCanonicalIdfrom StadiumIdentityServicehomeTeamCanonicalIdfrom 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):
- Bundled indexes - Date → stadium → home team lookup (no scores)
- Lazy-fetch scores - From API/scrape on first access, cache forever
- 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):
PHAsset.locationandPHAsset.creationDate(preferred)- EXIF via ImageIO:
kCGImagePropertyGPSLatitude,kCGImagePropertyExifDateTimeOriginal - 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):
- Official APIs (MLB Stats, NHL Stats)
- Unofficial APIs (ESPN, NBA Stats)
- 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:
- Show "We couldn't find this game automatically"
- Ask for home team, away team (autocomplete from known teams)
- Optionally ask for score
- 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 modelsSportsTime/Core/Models/Domain/Progress.swift- Domain structsSportsTime/Core/Models/Domain/Division.swift- League structureSportsTime/Core/Models/Domain/AchievementDefinitions.swift- Badge registrySportsTime/Core/Models/Domain/CanonicalGameKey.swift- Stable game identity + ResolvedGame
Services
SportsTime/Core/Services/StadiumIdentityService.swift- Canonical ID resolutionSportsTime/Core/Services/AchievementEngine.swift- Achievement computationSportsTime/Core/Services/HistoricalGameService.swift- Historical lookup orchestratorSportsTime/Core/Services/VisitPhotoService.swift- CloudKit photo sync
Photo Import Pipeline
SportsTime/Core/Services/PhotoMetadataExtractor.swift- EXIF extraction from PHAssetSportsTime/Core/Services/StadiumProximityMatcher.swift- GPS-to-stadium matchingSportsTime/Core/Services/GameMatcher.swift- Deterministic game resolution
Score Resolution
SportsTime/Core/Services/FreeScoreAPI.swift- Multi-provider API facadeSportsTime/Core/Services/ScoreAPIProviders/ESPNProvider.swift- ESPN unofficial APISportsTime/Core/Services/ScoreAPIProviders/MLBStatsProvider.swift- MLB Stats APISportsTime/Core/Services/ScoreAPIProviders/NHLStatsProvider.swift- NHL Stats APISportsTime/Core/Services/ScoreAPIProviders/NBAStatsProvider.swift- NBA Stats APISportsTime/Core/Services/ReferenceScrapingService.swift- Sports-Reference fallbackSportsTime/Core/Services/ScoreResolutionCache.swift- SwiftData cache for scoresSportsTime/Core/Services/RateLimiter.swift- Per-provider rate limiting
Features
SportsTime/Features/Progress/Views/ProgressTabView.swift- Main tabSportsTime/Features/Progress/Views/ProgressMapView.swift- Interactive mapSportsTime/Features/Progress/Views/StadiumVisitSheet.swift- Log visit (manual entry)SportsTime/Features/Progress/Views/PhotoImportView.swift- Photo picker + metadata extractionSportsTime/Features/Progress/Views/GameMatchConfirmationView.swift- Disambiguate multiple matchesSportsTime/Features/Progress/Views/VisitDetailView.swift- View/edit visitSportsTime/Features/Progress/Views/AchievementsListView.swift- Badge gallerySportsTime/Features/Progress/Views/ProgressShareView.swift- Share previewSportsTime/Features/Progress/ViewModels/ProgressViewModel.swift- Main stateSportsTime/Features/Progress/ViewModels/PhotoImportViewModel.swift- Photo import orchestration
Export
SportsTime/Export/Services/ProgressCardGenerator.swift- Shareable cards
Resources
Resources/stadium_identities.json- Canonical stadium mappingResources/league_structure.json- Division/conference dataResources/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 schemaSportsTime/Features/Home/Views/HomeView.swift- Add Progress tab
Implementation Phases
Phase 1: Data Foundation
- Create SwiftData models (StadiumVisit, Achievement, VisitPhotoMetadata, CachedGameScore)
- Create Division.swift with LeagueStructure
- Build StadiumIdentityService with bundled JSON
- Create CanonicalGameKey and ResolvedGame
- Create RateLimiter actor
- Update SportsTimeApp.swift schema
Phase 2: Core Progress UI
- Create ProgressViewModel
- Build ProgressTabView with league selector
- Implement ProgressMapView with MKMapView
- Add Progress tab to HomeView
Phase 3: Manual Visit Logging
- Create StadiumVisitSheet for manual entry
- Implement auto-fill from trip games
- Build VisitDetailView for viewing/editing
Phase 4: Photo Import Pipeline
- Build PhotoMetadataExtractor (EXIF from PHAsset)
- Create StadiumProximityMatcher (GPS → stadium)
- Implement GameMatcher (deterministic rules + confidence scoring)
- Build PhotoImportView with picker
- Create GameMatchConfirmationView for disambiguation
- Integrate into ProgressViewModel
Phase 5: Score Resolution
- Create ScoreAPIProvider protocol with reliability
- Implement MLB Stats API provider (official)
- Implement NHL Stats API provider (official)
- Implement NBA Stats API provider (unofficial)
- Implement ESPN API provider (unofficial, fallback)
- Build ReferenceScrapingService (Tier 3, scraped)
- Create ScoreResolutionCache
- Wire up FreeScoreAPI orchestrator with auto-disable
Phase 6: Achievements
- Create AchievementDefinitions registry
- Build AchievementEngine with computation logic
- Create achievement UI components
- Wire up achievement earned notifications
Phase 7: Photos & Sharing
- Implement VisitPhotoService with CloudKit
- Build photo gallery UI
- Create ProgressCardGenerator
- 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 |