Add Stadium Progress system and themed loading spinners

Stadium Progress & Achievements:
- Add StadiumVisit and Achievement SwiftData models
- Create Progress tab with interactive map view
- Implement photo-based visit import with GPS/date matching
- Add achievement badges (count-based, regional, journey)
- Create shareable progress cards for social media
- Add canonical data infrastructure (stadium identities, team aliases)
- Implement score resolution from free APIs (MLB, NBA, NHL stats)

UI Improvements:
- Add ThemedSpinner and ThemedSpinnerCompact components
- Replace all ProgressView() with themed spinners throughout app
- Fix sport selection state not persisting when navigating away

Bug Fixes:
- Fix Coast to Coast trips showing only 1 city (validation issue)
- Fix stadium progress showing 0/0 (filtering issue)
- Remove "Stadium Quest" title from progress view

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-08 20:20:03 -06:00
parent 2281440bf8
commit 92d808caf5
55 changed files with 14348 additions and 61 deletions

View File

@@ -0,0 +1,492 @@
//
// CanonicalModels.swift
// SportsTime
//
// SwiftData models for canonical data: stadiums, teams, games, and league structure.
// These are the runtime source of truth, populated from bundled JSON and synced via CloudKit.
//
import Foundation
import SwiftData
import CryptoKit
// MARK: - Schema Version
/// Schema version constants for canonical data models.
/// Marked nonisolated to allow access from any isolation domain.
nonisolated enum SchemaVersion {
static let current: Int = 1
static let minimumSupported: Int = 1
}
// MARK: - Data Source
enum DataSource: String, Codable {
case bundled // Shipped with app bundle
case cloudKit // Synced from CloudKit
case userCorrection // User-provided correction
}
// MARK: - Team Alias Type
enum TeamAliasType: String, Codable {
case abbreviation // Old abbreviation (e.g., "NJN" -> "BRK")
case name // Old team name (e.g., "New Jersey Nets")
case city // Old city (e.g., "New Jersey")
}
// MARK: - League Structure Type
enum LeagueStructureType: String, Codable {
case conference
case division
case league
}
// MARK: - Sync State
@Model
final class SyncState {
@Attribute(.unique) var id: String = "singleton"
// Bootstrap tracking
var bootstrapCompleted: Bool = false
var bundledSchemaVersion: Int = 0
var lastBootstrap: Date?
// CloudKit sync tracking
var lastSuccessfulSync: Date?
var lastSyncAttempt: Date?
var lastSyncError: String?
var syncInProgress: Bool = false
var syncEnabled: Bool = true
var syncPausedReason: String?
var consecutiveFailures: Int = 0
// Change tokens for delta sync
var stadiumChangeToken: Data?
var teamChangeToken: Data?
var gameChangeToken: Data?
var leagueChangeToken: Data?
init() {}
static func current(in context: ModelContext) -> SyncState {
let descriptor = FetchDescriptor<SyncState>(
predicate: #Predicate { $0.id == "singleton" }
)
if let existing = try? context.fetch(descriptor).first {
return existing
}
let new = SyncState()
context.insert(new)
return new
}
}
// MARK: - Canonical Stadium
@Model
final class CanonicalStadium {
// Identity
@Attribute(.unique) var canonicalId: String
var uuid: UUID
// Versioning
var schemaVersion: Int
var lastModified: Date
var sourceRaw: String
// Deprecation (soft delete)
var deprecatedAt: Date?
var deprecationReason: String?
var replacedByCanonicalId: String?
// Core data
var name: String
var city: String
var state: String
var latitude: Double
var longitude: Double
var capacity: Int
var yearOpened: Int?
var imageURL: String?
var sport: String
// User-correctable fields (preserved during sync)
var userNickname: String?
var userNotes: String?
var isFavorite: Bool = false
// Relationships
@Relationship(deleteRule: .cascade, inverse: \StadiumAlias.stadium)
var aliases: [StadiumAlias]?
init(
canonicalId: String,
uuid: UUID? = nil,
schemaVersion: Int = SchemaVersion.current,
lastModified: Date = Date(),
source: DataSource = .bundled,
name: String,
city: String,
state: String,
latitude: Double,
longitude: Double,
capacity: Int,
yearOpened: Int? = nil,
imageURL: String? = nil,
sport: String
) {
self.canonicalId = canonicalId
self.uuid = uuid ?? Self.deterministicUUID(from: canonicalId)
self.schemaVersion = schemaVersion
self.lastModified = lastModified
self.sourceRaw = source.rawValue
self.name = name
self.city = city
self.state = state
self.latitude = latitude
self.longitude = longitude
self.capacity = capacity
self.yearOpened = yearOpened
self.imageURL = imageURL
self.sport = sport
}
var source: DataSource {
get { DataSource(rawValue: sourceRaw) ?? .bundled }
set { sourceRaw = newValue.rawValue }
}
var isActive: Bool { deprecatedAt == nil }
func toDomain() -> Stadium {
Stadium(
id: uuid,
name: name,
city: city,
state: state,
latitude: latitude,
longitude: longitude,
capacity: capacity,
sport: Sport(rawValue: sport) ?? .mlb,
yearOpened: yearOpened,
imageURL: imageURL.flatMap { URL(string: $0) }
)
}
static func deterministicUUID(from string: String) -> UUID {
let data = Data(string.utf8)
let hash = SHA256.hash(data: data)
let hashBytes = Array(hash)
var bytes = Array(hashBytes.prefix(16))
// Set version 4 and variant bits
bytes[6] = (bytes[6] & 0x0F) | 0x40
bytes[8] = (bytes[8] & 0x3F) | 0x80
return UUID(uuid: (
bytes[0], bytes[1], bytes[2], bytes[3],
bytes[4], bytes[5], bytes[6], bytes[7],
bytes[8], bytes[9], bytes[10], bytes[11],
bytes[12], bytes[13], bytes[14], bytes[15]
))
}
}
// MARK: - Stadium Alias
@Model
final class StadiumAlias {
@Attribute(.unique) var aliasName: String
var stadiumCanonicalId: String
var validFrom: Date?
var validUntil: Date?
var schemaVersion: Int
var lastModified: Date
var stadium: CanonicalStadium?
init(
aliasName: String,
stadiumCanonicalId: String,
validFrom: Date? = nil,
validUntil: Date? = nil,
schemaVersion: Int = SchemaVersion.current,
lastModified: Date = Date()
) {
self.aliasName = aliasName.lowercased()
self.stadiumCanonicalId = stadiumCanonicalId
self.validFrom = validFrom
self.validUntil = validUntil
self.schemaVersion = schemaVersion
self.lastModified = lastModified
}
}
// MARK: - Canonical Team
@Model
final class CanonicalTeam {
@Attribute(.unique) var canonicalId: String
var uuid: UUID
var schemaVersion: Int
var lastModified: Date
var sourceRaw: String
var deprecatedAt: Date?
var deprecationReason: String?
var relocatedToCanonicalId: String?
var name: String
var abbreviation: String
var sport: String
var city: String
var stadiumCanonicalId: String
var logoURL: String?
var primaryColor: String?
var secondaryColor: String?
var conferenceId: String?
var divisionId: String?
// User-correctable
var userNickname: String?
var isFavorite: Bool = false
@Relationship(deleteRule: .cascade, inverse: \TeamAlias.team)
var aliases: [TeamAlias]?
init(
canonicalId: String,
uuid: UUID? = nil,
schemaVersion: Int = SchemaVersion.current,
lastModified: Date = Date(),
source: DataSource = .bundled,
name: String,
abbreviation: String,
sport: String,
city: String,
stadiumCanonicalId: String,
logoURL: String? = nil,
primaryColor: String? = nil,
secondaryColor: String? = nil,
conferenceId: String? = nil,
divisionId: String? = nil
) {
self.canonicalId = canonicalId
self.uuid = uuid ?? CanonicalStadium.deterministicUUID(from: canonicalId)
self.schemaVersion = schemaVersion
self.lastModified = lastModified
self.sourceRaw = source.rawValue
self.name = name
self.abbreviation = abbreviation
self.sport = sport
self.city = city
self.stadiumCanonicalId = stadiumCanonicalId
self.logoURL = logoURL
self.primaryColor = primaryColor
self.secondaryColor = secondaryColor
self.conferenceId = conferenceId
self.divisionId = divisionId
}
var source: DataSource {
get { DataSource(rawValue: sourceRaw) ?? .bundled }
set { sourceRaw = newValue.rawValue }
}
var isActive: Bool { deprecatedAt == nil }
var sportEnum: Sport? { Sport(rawValue: sport) }
func toDomain(stadiumUUID: UUID) -> Team {
Team(
id: uuid,
name: name,
abbreviation: abbreviation,
sport: sportEnum ?? .mlb,
city: city,
stadiumId: stadiumUUID,
logoURL: logoURL.flatMap { URL(string: $0) },
primaryColor: primaryColor,
secondaryColor: secondaryColor
)
}
}
// MARK: - Team Alias
@Model
final class TeamAlias {
@Attribute(.unique) var id: String
var teamCanonicalId: String
var aliasTypeRaw: String
var aliasValue: String
var validFrom: Date?
var validUntil: Date?
var schemaVersion: Int
var lastModified: Date
var team: CanonicalTeam?
init(
id: String,
teamCanonicalId: String,
aliasType: TeamAliasType,
aliasValue: String,
validFrom: Date? = nil,
validUntil: Date? = nil,
schemaVersion: Int = SchemaVersion.current,
lastModified: Date = Date()
) {
self.id = id
self.teamCanonicalId = teamCanonicalId
self.aliasTypeRaw = aliasType.rawValue
self.aliasValue = aliasValue
self.validFrom = validFrom
self.validUntil = validUntil
self.schemaVersion = schemaVersion
self.lastModified = lastModified
}
var aliasType: TeamAliasType {
get { TeamAliasType(rawValue: aliasTypeRaw) ?? .name }
set { aliasTypeRaw = newValue.rawValue }
}
}
// MARK: - League Structure
@Model
final class LeagueStructureModel {
@Attribute(.unique) var id: String
var sport: String
var structureTypeRaw: String
var name: String
var abbreviation: String?
var parentId: String?
var displayOrder: Int
var schemaVersion: Int
var lastModified: Date
init(
id: String,
sport: String,
structureType: LeagueStructureType,
name: String,
abbreviation: String? = nil,
parentId: String? = nil,
displayOrder: Int = 0,
schemaVersion: Int = SchemaVersion.current,
lastModified: Date = Date()
) {
self.id = id
self.sport = sport
self.structureTypeRaw = structureType.rawValue
self.name = name
self.abbreviation = abbreviation
self.parentId = parentId
self.displayOrder = displayOrder
self.schemaVersion = schemaVersion
self.lastModified = lastModified
}
var structureType: LeagueStructureType {
get { LeagueStructureType(rawValue: structureTypeRaw) ?? .division }
set { structureTypeRaw = newValue.rawValue }
}
var sportEnum: Sport? { Sport(rawValue: sport) }
}
// MARK: - Canonical Game
@Model
final class CanonicalGame {
@Attribute(.unique) var canonicalId: String
var uuid: UUID
var schemaVersion: Int
var lastModified: Date
var sourceRaw: String
var deprecatedAt: Date?
var deprecationReason: String?
var rescheduledToCanonicalId: String?
var homeTeamCanonicalId: String
var awayTeamCanonicalId: String
var stadiumCanonicalId: String
var dateTime: Date
var sport: String
var season: String
var isPlayoff: Bool
var broadcastInfo: String?
// User-correctable
var userAttending: Bool = false
var userNotes: String?
init(
canonicalId: String,
uuid: UUID? = nil,
schemaVersion: Int = SchemaVersion.current,
lastModified: Date = Date(),
source: DataSource = .bundled,
homeTeamCanonicalId: String,
awayTeamCanonicalId: String,
stadiumCanonicalId: String,
dateTime: Date,
sport: String,
season: String,
isPlayoff: Bool = false,
broadcastInfo: String? = nil
) {
self.canonicalId = canonicalId
self.uuid = uuid ?? CanonicalStadium.deterministicUUID(from: canonicalId)
self.schemaVersion = schemaVersion
self.lastModified = lastModified
self.sourceRaw = source.rawValue
self.homeTeamCanonicalId = homeTeamCanonicalId
self.awayTeamCanonicalId = awayTeamCanonicalId
self.stadiumCanonicalId = stadiumCanonicalId
self.dateTime = dateTime
self.sport = sport
self.season = season
self.isPlayoff = isPlayoff
self.broadcastInfo = broadcastInfo
}
var source: DataSource {
get { DataSource(rawValue: sourceRaw) ?? .bundled }
set { sourceRaw = newValue.rawValue }
}
var isActive: Bool { deprecatedAt == nil }
var sportEnum: Sport? { Sport(rawValue: sport) }
func toDomain(homeTeamUUID: UUID, awayTeamUUID: UUID, stadiumUUID: UUID) -> Game {
Game(
id: uuid,
homeTeamId: homeTeamUUID,
awayTeamId: awayTeamUUID,
stadiumId: stadiumUUID,
dateTime: dateTime,
sport: sportEnum ?? .mlb,
season: season,
isPlayoff: isPlayoff,
broadcastInfo: broadcastInfo
)
}
}
// MARK: - Bundled Data Timestamps
/// Timestamps for bundled data files.
/// Marked nonisolated to allow access from any isolation domain.
nonisolated enum BundledDataTimestamp {
static let stadiums = ISO8601DateFormatter().date(from: "2026-01-08T00:00:00Z") ?? Date()
static let games = ISO8601DateFormatter().date(from: "2026-01-08T00:00:00Z") ?? Date()
static let leagueStructure = ISO8601DateFormatter().date(from: "2026-01-08T00:00:00Z") ?? Date()
}

View File

@@ -0,0 +1,364 @@
//
// StadiumProgress.swift
// SportsTime
//
// SwiftData models for tracking stadium visits, achievements, and photo metadata.
//
import Foundation
import SwiftData
// MARK: - Visit Type
enum VisitType: String, Codable, CaseIterable {
case game // Attended a game
case tour // Stadium tour without game
case other // Other visit (tailgating, event, etc.)
var displayName: String {
switch self {
case .game: return "Game"
case .tour: return "Tour"
case .other: return "Other"
}
}
}
// MARK: - Data Source Type
enum VisitDataSource: 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
}
// MARK: - Score Source Type
enum ScoreSource: String, Codable {
case app // From app's schedule data
case api // From free sports API
case scraped // From reference site scraping
case user // User-provided
}
// MARK: - Visit Source Type
enum VisitSource: String, Codable {
case trip // Created from a planned trip
case manual // Manually entered
case photoImport // Imported from photo library
}
// MARK: - Upload Status
enum UploadStatus: String, Codable {
case pending
case uploaded
case failed
}
// MARK: - Stadium Visit
@Model
final class StadiumVisit {
@Attribute(.unique) var id: UUID
// Stadium identity (stable across renames)
var canonicalStadiumId: String // Links to CanonicalStadium.canonicalId
var stadiumUUID: UUID // Runtime UUID for display lookups
var stadiumNameAtVisit: String // Frozen at visit time
// Visit details
var visitDate: Date
var sport: String // Sport.rawValue
var visitTypeRaw: String // VisitType.rawValue
// Game info (optional - nil for tours/other visits)
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 scoreSourceRaw: String? // ScoreSource.rawValue
var dataSourceRaw: String // VisitDataSource.rawValue
var scoreResolutionPending: Bool // true if background retry needed
// User data
var seatLocation: String?
var notes: String?
// Photos
@Relationship(deleteRule: .cascade, inverse: \VisitPhotoMetadata.visit)
var photoMetadata: [VisitPhotoMetadata]?
// Photo import metadata (preserved for debugging/re-matching)
var photoLatitude: Double?
var photoLongitude: Double?
var photoCaptureDate: Date?
// Audit
var createdAt: Date
var sourceRaw: String // VisitSource.rawValue
// MARK: - Initialization
init(
id: UUID = UUID(),
canonicalStadiumId: String,
stadiumUUID: UUID,
stadiumNameAtVisit: String,
visitDate: Date,
sport: Sport,
visitType: VisitType = .game,
gameId: UUID? = nil,
homeTeamId: UUID? = nil,
awayTeamId: UUID? = nil,
homeTeamName: String? = nil,
awayTeamName: String? = nil,
finalScore: String? = nil,
manualGameDescription: String? = nil,
scoreSource: ScoreSource? = nil,
dataSource: VisitDataSource = .fullyManual,
scoreResolutionPending: Bool = false,
seatLocation: String? = nil,
notes: String? = nil,
photoLatitude: Double? = nil,
photoLongitude: Double? = nil,
photoCaptureDate: Date? = nil,
source: VisitSource = .manual
) {
self.id = id
self.canonicalStadiumId = canonicalStadiumId
self.stadiumUUID = stadiumUUID
self.stadiumNameAtVisit = stadiumNameAtVisit
self.visitDate = visitDate
self.sport = sport.rawValue
self.visitTypeRaw = visitType.rawValue
self.gameId = gameId
self.homeTeamId = homeTeamId
self.awayTeamId = awayTeamId
self.homeTeamName = homeTeamName
self.awayTeamName = awayTeamName
self.finalScore = finalScore
self.manualGameDescription = manualGameDescription
self.scoreSourceRaw = scoreSource?.rawValue
self.dataSourceRaw = dataSource.rawValue
self.scoreResolutionPending = scoreResolutionPending
self.seatLocation = seatLocation
self.notes = notes
self.photoLatitude = photoLatitude
self.photoLongitude = photoLongitude
self.photoCaptureDate = photoCaptureDate
self.createdAt = Date()
self.sourceRaw = source.rawValue
}
// MARK: - Computed Properties
var visitType: VisitType {
get { VisitType(rawValue: visitTypeRaw) ?? .game }
set { visitTypeRaw = newValue.rawValue }
}
var scoreSource: ScoreSource? {
get { scoreSourceRaw.flatMap { ScoreSource(rawValue: $0) } }
set { scoreSourceRaw = newValue?.rawValue }
}
var dataSource: VisitDataSource {
get { VisitDataSource(rawValue: dataSourceRaw) ?? .fullyManual }
set { dataSourceRaw = newValue.rawValue }
}
var source: VisitSource {
get { VisitSource(rawValue: sourceRaw) ?? .manual }
set { sourceRaw = newValue.rawValue }
}
var sportEnum: Sport? {
Sport(rawValue: sport)
}
/// Display string for the game matchup
var matchupDescription: String? {
if let home = homeTeamName, let away = awayTeamName {
return "\(away) @ \(home)"
}
return manualGameDescription
}
/// Display string including score if available
var matchupWithScore: String? {
guard let matchup = matchupDescription else { return nil }
if let score = finalScore {
return "\(matchup) (\(score))"
}
return matchup
}
}
// MARK: - Visit Photo Metadata
@Model
final class VisitPhotoMetadata {
@Attribute(.unique) var id: UUID
var visitId: UUID
var cloudKitAssetId: String? // Set after successful upload
var thumbnailData: Data? // 200x200 JPEG for fast loading
var caption: String?
var orderIndex: Int
var uploadStatusRaw: String // UploadStatus.rawValue
var createdAt: Date
var visit: StadiumVisit?
init(
id: UUID = UUID(),
visitId: UUID,
cloudKitAssetId: String? = nil,
thumbnailData: Data? = nil,
caption: String? = nil,
orderIndex: Int = 0,
uploadStatus: UploadStatus = .pending
) {
self.id = id
self.visitId = visitId
self.cloudKitAssetId = cloudKitAssetId
self.thumbnailData = thumbnailData
self.caption = caption
self.orderIndex = orderIndex
self.uploadStatusRaw = uploadStatus.rawValue
self.createdAt = Date()
}
var uploadStatus: UploadStatus {
get { UploadStatus(rawValue: uploadStatusRaw) ?? .pending }
set { uploadStatusRaw = newValue.rawValue }
}
}
// MARK: - Achievement
@Model
final class Achievement {
@Attribute(.unique) var id: UUID
var achievementTypeId: String // e.g., "mlb_all_30", "nl_west_complete"
var sport: String? // Sport.rawValue, nil for cross-sport achievements
var earnedAt: Date
var revokedAt: Date? // Non-nil if visits deleted
var visitIdsSnapshot: Data // JSON-encoded [UUID] that earned this
init(
id: UUID = UUID(),
achievementTypeId: String,
sport: Sport? = nil,
earnedAt: Date = Date(),
visitIds: [UUID]
) {
self.id = id
self.achievementTypeId = achievementTypeId
self.sport = sport?.rawValue
self.earnedAt = earnedAt
self.revokedAt = nil
self.visitIdsSnapshot = (try? JSONEncoder().encode(visitIds)) ?? Data()
}
var sportEnum: Sport? {
sport.flatMap { Sport(rawValue: $0) }
}
var visitIds: [UUID] {
(try? JSONDecoder().decode([UUID].self, from: visitIdsSnapshot)) ?? []
}
var isEarned: Bool {
revokedAt == nil
}
func revoke() {
revokedAt = Date()
}
func restore() {
revokedAt = nil
}
}
// MARK: - Cached Game Score
/// Caches resolved game scores to avoid repeated API calls.
/// Historical scores never change, so they can be cached indefinitely.
@Model
final class CachedGameScore {
@Attribute(.unique) var cacheKey: String // "MLB_2010-06-15_SFG_LAD"
var sport: String
var gameDate: Date
var homeTeamAbbrev: String
var awayTeamAbbrev: String
var homeTeamName: String
var awayTeamName: String
var homeScore: Int?
var awayScore: Int?
var sourceRaw: String // ScoreSource.rawValue
var fetchedAt: Date
var expiresAt: Date? // nil = never expires (historical data)
init(
cacheKey: String,
sport: Sport,
gameDate: Date,
homeTeamAbbrev: String,
awayTeamAbbrev: String,
homeTeamName: String,
awayTeamName: String,
homeScore: Int?,
awayScore: Int?,
source: ScoreSource,
expiresAt: Date? = nil
) {
self.cacheKey = cacheKey
self.sport = sport.rawValue
self.gameDate = gameDate
self.homeTeamAbbrev = homeTeamAbbrev
self.awayTeamAbbrev = awayTeamAbbrev
self.homeTeamName = homeTeamName
self.awayTeamName = awayTeamName
self.homeScore = homeScore
self.awayScore = awayScore
self.sourceRaw = source.rawValue
self.fetchedAt = Date()
self.expiresAt = expiresAt
}
var scoreSource: ScoreSource {
get { ScoreSource(rawValue: sourceRaw) ?? .api }
set { sourceRaw = newValue.rawValue }
}
var sportEnum: Sport? {
Sport(rawValue: sport)
}
var scoreString: String? {
guard let home = homeScore, let away = awayScore else { return nil }
return "\(away)-\(home)"
}
var isExpired: Bool {
guard let expiresAt = expiresAt else { return false }
return Date() > expiresAt
}
/// Generate cache key for a game query
static func generateKey(sport: Sport, date: Date, homeAbbrev: String, awayAbbrev: String) -> String {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd"
let dateString = dateFormatter.string(from: date)
return "\(sport.rawValue)_\(dateString)_\(homeAbbrev)_\(awayAbbrev)"
}
}