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:
841
docs/STADIUM_PROGRESS_SPEC.md
Normal file
841
docs/STADIUM_PROGRESS_SPEC.md
Normal 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 |
|
||||
Reference in New Issue
Block a user