Fixes ~95 issues from deep audit across 12 categories in 82 files: - Crash prevention: double-resume in PhotoMetadataExtractor, force unwraps in DateRangePicker, array bounds checks in polls/achievements, ProGate hit-test bypass, Dictionary(uniqueKeysWithValues:) → uniquingKeysWith in 4 files - Silent failure elimination: all 34 try? sites replaced with do/try/catch + logging (SavedTrip, TripDetailView, CanonicalSyncService, BootstrapService, CanonicalModels, CKModels, SportsTimeApp, and more) - Performance: cached DateFormatters (7 files), O(1) team lookups via AppDataProvider, achievement definition dictionary, AnimatedBackground consolidated from 19 Tasks to 1, task cancellation in SharePreviewView - Concurrency: UIKit drawing → MainActor.run, background fetch timeout guard, @MainActor on ThemeManager/AppearanceManager, SyncLogger read/write race fix - Planning engine: game end time in travel feasibility, state-aware city normalization, exact city matching, DrivingConstraints parameter propagation - IAP: unknown subscription states → expired, unverified transaction logging, entitlements updated before paywall dismiss, restore visible to all users - Security: API key to Info.plist lookup, filename sanitization in PDF export, honest User-Agent, removed stale "Feels" analytics super properties - Navigation: consolidated competing navigationDestination, boolean → value-based - Testing: 8 sleep() → waitForExistence, duplicates extracted, Swift 6 compat - Service bugs: infinite retry cap, duplicate achievement prevention, TOCTOU vote fix, PollVote.odg → voterId rename, deterministic placeholder IDs, parallel MKDirections, Sendable-safe POI struct Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
467 lines
17 KiB
Swift
467 lines
17 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
|
|
private var stadiumIdsBySport: [Sport: [String]]?
|
|
|
|
// MARK: - Initialization
|
|
|
|
init(modelContext: ModelContext, dataProvider: AppDataProvider) {
|
|
self.modelContext = modelContext
|
|
self.dataProvider = dataProvider
|
|
}
|
|
|
|
/// Convenience initializer using the shared data provider
|
|
convenience init(modelContext: ModelContext) {
|
|
self.init(modelContext: modelContext, dataProvider: AppDataProvider.shared)
|
|
}
|
|
|
|
// 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.stadiumId })
|
|
|
|
// 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.stadiumId })
|
|
|
|
let currentEarnedAchievements = try fetchEarnedAchievements()
|
|
let currentEarnedIds = Set(currentEarnedAchievements.map { $0.achievementTypeId })
|
|
let allAchievements = try fetchAllAchievements()
|
|
|
|
var newlyEarned: [AchievementDefinition] = []
|
|
|
|
for definition in AchievementRegistry.all {
|
|
// Skip already earned (active, non-revoked)
|
|
guard !currentEarnedIds.contains(definition.id) else { continue }
|
|
|
|
let isEarned = checkRequirement(definition.requirement, visits: visits, visitedStadiumIds: visitedStadiumIds)
|
|
|
|
if isEarned {
|
|
newlyEarned.append(definition)
|
|
|
|
// Check if a revoked achievement already exists — restore it instead of creating a duplicate
|
|
if let revokedAchievement = allAchievements.first(where: {
|
|
$0.achievementTypeId == definition.id && $0.revokedAt != nil
|
|
}) {
|
|
revokedAchievement.restore()
|
|
} else {
|
|
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.stadiumId })
|
|
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 hasStoredAchievement = earnedIds.contains(definition.id)
|
|
let earnedAt = earnedAchievements.first(where: { $0.achievementTypeId == definition.id })?.earnedAt
|
|
|
|
progress.append(AchievementProgress(
|
|
definition: definition,
|
|
currentProgress: current,
|
|
totalRequired: total,
|
|
hasStoredAchievement: hasStoredAchievement,
|
|
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.stadiumId })
|
|
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):
|
|
// Direct comparison - canonical IDs match everywhere
|
|
return visitedStadiumIds.contains(stadiumId)
|
|
}
|
|
}
|
|
|
|
private func checkDivisionComplete(_ divisionId: String, visitedStadiumIds: Set<String>) -> Bool {
|
|
guard LeagueStructure.division(byId: divisionId) != nil 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 LeagueStructure.conference(byId: conferenceId) != nil 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.stadiumId })
|
|
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.stadiumId })
|
|
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):
|
|
// Direct comparison - canonical IDs match everywhere
|
|
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.stadiumId) }.map { $0.id }
|
|
|
|
case .completeConference(let conferenceId):
|
|
let stadiumIds = Set(getStadiumIdsForConference(conferenceId))
|
|
return visits.filter { stadiumIds.contains($0.stadiumId) }.map { $0.id }
|
|
|
|
case .completeLeague(let sport):
|
|
let stadiumIds = Set(getStadiumIdsForLeague(sport))
|
|
return visits.filter { stadiumIds.contains($0.stadiumId) }.map { $0.id }
|
|
|
|
case .visitsInDays(let requiredVisits, let days):
|
|
// Find the qualifying window of visits
|
|
let sortedVisits = visits.sorted { $0.visitDate < $1.visitDate }
|
|
guard sortedVisits.count >= requiredVisits else { return [] }
|
|
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):
|
|
// Direct comparison - canonical IDs match everywhere
|
|
return visits.filter { $0.stadiumId == stadiumId }.map { $0.id }
|
|
}
|
|
}
|
|
|
|
// MARK: - Stadium Lookups
|
|
|
|
private func getStadiumIdsForDivision(_ divisionId: String) -> [String] {
|
|
// Use AppDataProvider for canonical data reads
|
|
return dataProvider.teams
|
|
.filter { $0.divisionId == divisionId }
|
|
.map { $0.stadiumId }
|
|
}
|
|
|
|
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] {
|
|
// Build cache lazily on first access
|
|
if stadiumIdsBySport == nil {
|
|
var cache: [Sport: [String]] = [:]
|
|
for team in dataProvider.teams {
|
|
cache[team.sport, default: []].append(team.stadiumId)
|
|
}
|
|
stadiumIdsBySport = cache
|
|
}
|
|
return stadiumIdsBySport?[sport] ?? []
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
private func fetchAllAchievements() throws -> [Achievement] {
|
|
let descriptor = FetchDescriptor<Achievement>()
|
|
return try modelContext.fetch(descriptor)
|
|
}
|
|
}
|
|
|
|
// MARK: - Achievement Progress
|
|
|
|
struct AchievementProgress: Identifiable {
|
|
let definition: AchievementDefinition
|
|
let currentProgress: Int
|
|
let totalRequired: Int
|
|
let hasStoredAchievement: Bool // Whether an Achievement record exists in SwiftData
|
|
let earnedAt: Date?
|
|
|
|
var id: String { definition.id }
|
|
|
|
/// Whether the achievement is earned (either stored or computed from progress)
|
|
var isEarned: Bool {
|
|
// Earned if we have a stored record OR if progress is complete
|
|
hasStoredAchievement || (totalRequired > 0 && currentProgress >= totalRequired)
|
|
}
|
|
|
|
var progressPercentage: Double {
|
|
guard totalRequired > 0 else { return 0 }
|
|
return Double(currentProgress) / Double(totalRequired)
|
|
}
|
|
|
|
var progressText: String {
|
|
if isEarned {
|
|
return "Completed"
|
|
}
|
|
return "\(currentProgress)/\(totalRequired)"
|
|
}
|
|
}
|