Files
Sportstime/SportsTime/Core/Services/AchievementEngine.swift
Trey t 92d808caf5 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>
2026-01-08 20:20:03 -06:00

445 lines
16 KiB
Swift

//
// AchievementEngine.swift
// SportsTime
//
// Computes achievements based on stadium visits.
// Recalculates and revokes achievements when visits are deleted.
//
import Foundation
import SwiftData
// MARK: - Achievement Delta
struct AchievementDelta: Sendable {
let newlyEarned: [AchievementDefinition]
let revoked: [AchievementDefinition]
let stillEarned: [AchievementDefinition]
var hasChanges: Bool {
!newlyEarned.isEmpty || !revoked.isEmpty
}
}
// MARK: - Achievement Engine
@MainActor
final class AchievementEngine {
// MARK: - Properties
private let modelContext: ModelContext
private let dataProvider: AppDataProvider
// MARK: - Initialization
init(modelContext: ModelContext, dataProvider: AppDataProvider = AppDataProvider.shared) {
self.modelContext = modelContext
self.dataProvider = dataProvider
}
// MARK: - Public API
/// Full recalculation (call after visit deleted or on app update)
func recalculateAllAchievements() async throws -> AchievementDelta {
// Get all visits
let visits = try fetchAllVisits()
let visitedStadiumIds = Set(visits.map { $0.canonicalStadiumId })
// Get currently earned achievements
let currentAchievements = try fetchEarnedAchievements()
let currentAchievementIds = Set(currentAchievements.map { $0.achievementTypeId })
// Calculate which achievements should be earned
var shouldBeEarned: Set<String> = []
var newlyEarnedDefinitions: [AchievementDefinition] = []
var revokedDefinitions: [AchievementDefinition] = []
var stillEarnedDefinitions: [AchievementDefinition] = []
for definition in AchievementRegistry.all {
let isEarned = checkRequirement(definition.requirement, visits: visits, visitedStadiumIds: visitedStadiumIds)
if isEarned {
shouldBeEarned.insert(definition.id)
if currentAchievementIds.contains(definition.id) {
stillEarnedDefinitions.append(definition)
} else {
newlyEarnedDefinitions.append(definition)
}
} else if currentAchievementIds.contains(definition.id) {
revokedDefinitions.append(definition)
}
}
// Apply changes
// Grant new achievements
for definition in newlyEarnedDefinitions {
let visitIds = getContributingVisitIds(for: definition.requirement, visits: visits)
let achievement = Achievement(
achievementTypeId: definition.id,
sport: definition.sport,
visitIds: visitIds
)
modelContext.insert(achievement)
}
// Revoke achievements
for definition in revokedDefinitions {
if let achievement = currentAchievements.first(where: { $0.achievementTypeId == definition.id }) {
achievement.revoke()
}
}
// Restore previously revoked achievements that are now earned again
for definition in stillEarnedDefinitions {
if let achievement = currentAchievements.first(where: {
$0.achievementTypeId == definition.id && $0.revokedAt != nil
}) {
achievement.restore()
}
}
try modelContext.save()
return AchievementDelta(
newlyEarned: newlyEarnedDefinitions,
revoked: revokedDefinitions,
stillEarned: stillEarnedDefinitions
)
}
/// Quick check after new visit (incremental)
func checkAchievementsForNewVisit(_ visit: StadiumVisit) async throws -> [AchievementDefinition] {
let visits = try fetchAllVisits()
let visitedStadiumIds = Set(visits.map { $0.canonicalStadiumId })
let currentAchievements = try fetchEarnedAchievements()
let currentAchievementIds = Set(currentAchievements.map { $0.achievementTypeId })
var newlyEarned: [AchievementDefinition] = []
for definition in AchievementRegistry.all {
// Skip already earned
guard !currentAchievementIds.contains(definition.id) else { continue }
let isEarned = checkRequirement(definition.requirement, visits: visits, visitedStadiumIds: visitedStadiumIds)
if isEarned {
newlyEarned.append(definition)
let visitIds = getContributingVisitIds(for: definition.requirement, visits: visits)
let achievement = Achievement(
achievementTypeId: definition.id,
sport: definition.sport,
visitIds: visitIds
)
modelContext.insert(achievement)
}
}
try modelContext.save()
return newlyEarned
}
/// Get all earned achievements
func getEarnedAchievements() throws -> [AchievementDefinition] {
let achievements = try fetchEarnedAchievements()
return achievements.compactMap { AchievementRegistry.achievement(byId: $0.achievementTypeId) }
}
/// Get progress toward all achievements
func getProgress() async throws -> [AchievementProgress] {
let visits = try fetchAllVisits()
let visitedStadiumIds = Set(visits.map { $0.canonicalStadiumId })
let earnedAchievements = try fetchEarnedAchievements()
let earnedIds = Set(earnedAchievements.map { $0.achievementTypeId })
var progress: [AchievementProgress] = []
for definition in AchievementRegistry.all {
let (current, total) = calculateProgress(
for: definition.requirement,
visits: visits,
visitedStadiumIds: visitedStadiumIds
)
let isEarned = earnedIds.contains(definition.id)
let earnedAt = earnedAchievements.first(where: { $0.achievementTypeId == definition.id })?.earnedAt
progress.append(AchievementProgress(
definition: definition,
currentProgress: current,
totalRequired: total,
isEarned: isEarned,
earnedAt: earnedAt
))
}
return progress
}
// MARK: - Requirement Checking
private func checkRequirement(
_ requirement: AchievementRequirement,
visits: [StadiumVisit],
visitedStadiumIds: Set<String>
) -> Bool {
switch requirement {
case .firstVisit:
return !visits.isEmpty
case .visitCount(let count):
return visitedStadiumIds.count >= count
case .visitCountForSport(let count, let sport):
let sportVisits = visits.filter { $0.sport == sport.rawValue }
let sportStadiums = Set(sportVisits.map { $0.canonicalStadiumId })
return sportStadiums.count >= count
case .completeDivision(let divisionId):
return checkDivisionComplete(divisionId, visitedStadiumIds: visitedStadiumIds)
case .completeConference(let conferenceId):
return checkConferenceComplete(conferenceId, visitedStadiumIds: visitedStadiumIds)
case .completeLeague(let sport):
return checkLeagueComplete(sport, visitedStadiumIds: visitedStadiumIds)
case .visitsInDays(let visitCount, let days):
return checkVisitsInDays(visits: visits, requiredVisits: visitCount, withinDays: days)
case .multipleLeagues(let leagueCount):
return checkMultipleLeagues(visits: visits, requiredLeagues: leagueCount)
case .specificStadium(let stadiumId):
return visitedStadiumIds.contains(stadiumId)
}
}
private func checkDivisionComplete(_ divisionId: String, visitedStadiumIds: Set<String>) -> Bool {
guard let division = LeagueStructure.division(byId: divisionId) else { return false }
// Get stadium IDs for teams in this division
let stadiumIds = getStadiumIdsForDivision(divisionId)
guard !stadiumIds.isEmpty else { return false }
return stadiumIds.allSatisfy { visitedStadiumIds.contains($0) }
}
private func checkConferenceComplete(_ conferenceId: String, visitedStadiumIds: Set<String>) -> Bool {
guard let conference = LeagueStructure.conference(byId: conferenceId) else { return false }
// Get stadium IDs for all teams in this conference
let stadiumIds = getStadiumIdsForConference(conferenceId)
guard !stadiumIds.isEmpty else { return false }
return stadiumIds.allSatisfy { visitedStadiumIds.contains($0) }
}
private func checkLeagueComplete(_ sport: Sport, visitedStadiumIds: Set<String>) -> Bool {
let stadiumIds = getStadiumIdsForLeague(sport)
guard !stadiumIds.isEmpty else { return false }
return stadiumIds.allSatisfy { visitedStadiumIds.contains($0) }
}
private func checkVisitsInDays(visits: [StadiumVisit], requiredVisits: Int, withinDays: Int) -> Bool {
guard visits.count >= requiredVisits else { return false }
// Sort visits by date
let sortedVisits = visits.sorted { $0.visitDate < $1.visitDate }
// Sliding window
for i in 0...(sortedVisits.count - requiredVisits) {
let windowStart = sortedVisits[i].visitDate
let windowEnd = sortedVisits[i + requiredVisits - 1].visitDate
let daysDiff = Calendar.current.dateComponents([.day], from: windowStart, to: windowEnd).day ?? Int.max
if daysDiff < withinDays {
// Check unique stadiums in window
let windowVisits = Array(sortedVisits[i..<(i + requiredVisits)])
let uniqueStadiums = Set(windowVisits.map { $0.canonicalStadiumId })
if uniqueStadiums.count >= requiredVisits {
return true
}
}
}
return false
}
private func checkMultipleLeagues(visits: [StadiumVisit], requiredLeagues: Int) -> Bool {
let leagues = Set(visits.compactMap { Sport(rawValue: $0.sport) })
return leagues.count >= requiredLeagues
}
// MARK: - Progress Calculation
private func calculateProgress(
for requirement: AchievementRequirement,
visits: [StadiumVisit],
visitedStadiumIds: Set<String>
) -> (current: Int, total: Int) {
switch requirement {
case .firstVisit:
return (visits.isEmpty ? 0 : 1, 1)
case .visitCount(let count):
return (visitedStadiumIds.count, count)
case .visitCountForSport(let count, let sport):
let sportVisits = visits.filter { $0.sport == sport.rawValue }
let sportStadiums = Set(sportVisits.map { $0.canonicalStadiumId })
return (sportStadiums.count, count)
case .completeDivision(let divisionId):
let stadiumIds = getStadiumIdsForDivision(divisionId)
let visited = stadiumIds.filter { visitedStadiumIds.contains($0) }.count
return (visited, stadiumIds.count)
case .completeConference(let conferenceId):
let stadiumIds = getStadiumIdsForConference(conferenceId)
let visited = stadiumIds.filter { visitedStadiumIds.contains($0) }.count
return (visited, stadiumIds.count)
case .completeLeague(let sport):
let stadiumIds = getStadiumIdsForLeague(sport)
let visited = stadiumIds.filter { visitedStadiumIds.contains($0) }.count
return (visited, stadiumIds.count)
case .visitsInDays(let visitCount, _):
// For journey achievements, show total unique stadiums vs required
return (min(visitedStadiumIds.count, visitCount), visitCount)
case .multipleLeagues(let leagueCount):
let leagues = Set(visits.compactMap { Sport(rawValue: $0.sport) })
return (leagues.count, leagueCount)
case .specificStadium(let stadiumId):
return (visitedStadiumIds.contains(stadiumId) ? 1 : 0, 1)
}
}
// MARK: - Contributing Visits
private func getContributingVisitIds(for requirement: AchievementRequirement, visits: [StadiumVisit]) -> [UUID] {
switch requirement {
case .firstVisit:
return visits.first.map { [$0.id] } ?? []
case .visitCount, .visitCountForSport, .multipleLeagues:
// All visits contribute
return visits.map { $0.id }
case .completeDivision(let divisionId):
let stadiumIds = Set(getStadiumIdsForDivision(divisionId))
return visits.filter { stadiumIds.contains($0.canonicalStadiumId) }.map { $0.id }
case .completeConference(let conferenceId):
let stadiumIds = Set(getStadiumIdsForConference(conferenceId))
return visits.filter { stadiumIds.contains($0.canonicalStadiumId) }.map { $0.id }
case .completeLeague(let sport):
let stadiumIds = Set(getStadiumIdsForLeague(sport))
return visits.filter { stadiumIds.contains($0.canonicalStadiumId) }.map { $0.id }
case .visitsInDays(let requiredVisits, let days):
// Find the qualifying window of visits
let sortedVisits = visits.sorted { $0.visitDate < $1.visitDate }
for i in 0...(sortedVisits.count - requiredVisits) {
let windowStart = sortedVisits[i].visitDate
let windowEnd = sortedVisits[i + requiredVisits - 1].visitDate
let daysDiff = Calendar.current.dateComponents([.day], from: windowStart, to: windowEnd).day ?? Int.max
if daysDiff < days {
return Array(sortedVisits[i..<(i + requiredVisits)]).map { $0.id }
}
}
return []
case .specificStadium(let stadiumId):
return visits.filter { $0.canonicalStadiumId == stadiumId }.map { $0.id }
}
}
// MARK: - Stadium Lookups
private func getStadiumIdsForDivision(_ divisionId: String) -> [String] {
// Get teams in division, then their stadiums
let teams = dataProvider.teams.filter { team in
// Match division by checking team's division assignment
// This would normally come from CanonicalTeam.divisionId
// For now, return based on division structure
return false // Will be populated when division data is linked
}
// For now, return hardcoded counts based on typical division sizes
// This should be replaced with actual team-to-stadium mapping
return []
}
private func getStadiumIdsForConference(_ conferenceId: String) -> [String] {
guard let conference = LeagueStructure.conference(byId: conferenceId) else { return [] }
var stadiumIds: [String] = []
for divisionId in conference.divisionIds {
stadiumIds.append(contentsOf: getStadiumIdsForDivision(divisionId))
}
return stadiumIds
}
private func getStadiumIdsForLeague(_ sport: Sport) -> [String] {
// Get all stadiums for this sport
return dataProvider.stadiums
.filter { stadium in
// Check if stadium hosts teams of this sport
dataProvider.teams.contains { team in
team.stadiumId == stadium.id && team.sport == sport
}
}
.map { "stadium_\(sport.rawValue.lowercased())_\($0.id.uuidString)" }
}
// MARK: - Data Fetching
private func fetchAllVisits() throws -> [StadiumVisit] {
let descriptor = FetchDescriptor<StadiumVisit>(
sortBy: [SortDescriptor(\.visitDate, order: .forward)]
)
return try modelContext.fetch(descriptor)
}
private func fetchEarnedAchievements() throws -> [Achievement] {
let descriptor = FetchDescriptor<Achievement>(
predicate: #Predicate { $0.revokedAt == nil }
)
return try modelContext.fetch(descriptor)
}
}
// MARK: - Achievement Progress
struct AchievementProgress: Identifiable {
let definition: AchievementDefinition
let currentProgress: Int
let totalRequired: Int
let isEarned: Bool
let earnedAt: Date?
var id: String { definition.id }
var progressPercentage: Double {
guard totalRequired > 0 else { return 0 }
return Double(currentProgress) / Double(totalRequired)
}
var progressText: String {
if isEarned {
return "Completed"
}
return "\(currentProgress)/\(totalRequired)"
}
}