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>
842 lines
27 KiB
Markdown
842 lines
27 KiB
Markdown
# 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 |
|