Files
Sportstime/SportsTime/Features/Progress/ViewModels/ProgressViewModel.swift
Trey t c94e373e33 fix: comprehensive codebase hardening — crashes, silent failures, performance, and security
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>
2026-02-27 17:03:09 -06:00

238 lines
7.7 KiB
Swift

//
// ProgressViewModel.swift
// SportsTime
//
// ViewModel for stadium progress tracking and visualization.
//
import Foundation
import SwiftUI
import SwiftData
@MainActor
@Observable
final class ProgressViewModel {
// MARK: - State
var selectedSport: Sport = .mlb
var isLoading = true
var error: Error?
var errorMessage: String?
// MARK: - Data
private(set) var visits: [StadiumVisit] = []
private(set) var stadiums: [Stadium] = []
private(set) var teams: [Team] = []
// MARK: - Dependencies
private var modelContainer: ModelContainer?
private var modelContext: ModelContext?
private let dataProvider = AppDataProvider.shared
// MARK: - Derived State (recomputed after mutations)
/// Overall progress for the selected sport
private(set) var leagueProgress: LeagueProgress = LeagueProgress(sport: .mlb, totalStadiums: 0, visitedStadiums: 0, stadiumsVisited: [], stadiumsRemaining: [])
/// Stadium visit status indexed by stadium ID
private(set) var stadiumVisitStatus: [String: StadiumVisitStatus] = [:]
/// Visited stadiums for the selected sport
private(set) var visitedStadiums: [Stadium] = []
/// Unvisited stadiums for the selected sport
private(set) var unvisitedStadiums: [Stadium] = []
/// Pre-computed progress fractions per sport (avoids filtering visits per sport per render)
private(set) var sportProgressFractions: [Sport: Double] = [:]
/// Stadiums for the selected sport (cached, recomputed on data change)
private(set) var sportStadiums: [Stadium] = []
/// Count of trips for the selected sport (stub - can be enhanced)
var tripCount: Int {
savedTripCount
}
/// Recent visits sorted by date (cached, recomputed on data change)
private(set) var recentVisits: [VisitSummary] = []
private(set) var savedTripCount: Int = 0
// MARK: - Configuration
func configure(with container: ModelContainer) {
self.modelContainer = container
self.modelContext = ModelContext(container)
}
// MARK: - Actions
func loadData() async {
isLoading = true
error = nil
errorMessage = nil
do {
// Load stadiums and teams from data provider
if dataProvider.stadiums.isEmpty {
await dataProvider.loadInitialData()
}
stadiums = dataProvider.stadiums
teams = dataProvider.teams
// Load visits from SwiftData
if let context = modelContext {
let descriptor = FetchDescriptor<StadiumVisit>(
sortBy: [SortDescriptor(\.visitDate, order: .reverse)]
)
visits = try context.fetch(descriptor)
savedTripCount = try context.fetchCount(FetchDescriptor<SavedTrip>())
}
} catch {
self.error = error
self.errorMessage = error.localizedDescription
}
recomputeDerivedState()
isLoading = false
}
func selectSport(_ sport: Sport) {
selectedSport = sport
recomputeDerivedState()
AnalyticsManager.shared.track(.sportSwitched(sport: sport.rawValue))
}
func clearError() {
error = nil
errorMessage = nil
}
// MARK: - Visit Management
func deleteVisit(_ visit: StadiumVisit) async throws {
guard let context = modelContext else { return }
if let sport = visit.sportEnum {
AnalyticsManager.shared.track(.stadiumVisitDeleted(stadiumId: visit.stadiumId, sport: sport.rawValue))
}
context.delete(visit)
try context.save()
// Reload data
await loadData()
}
// MARK: - Derived State Recomputation
private func recomputeDerivedState() {
// Compute league progress once
let filteredStadiums = stadiums.filter { $0.sport == selectedSport }
self.sportStadiums = filteredStadiums
let visitedStadiumIds = Set(
visits
.filter { $0.sportEnum == selectedSport }
.compactMap { visit -> String? in
dataProvider.stadium(for: visit.stadiumId)?.id
}
)
let visited = filteredStadiums.filter { visitedStadiumIds.contains($0.id) }
let remaining = filteredStadiums.filter { !visitedStadiumIds.contains($0.id) }
leagueProgress = LeagueProgress(
sport: selectedSport,
totalStadiums: filteredStadiums.count,
visitedStadiums: visited.count,
stadiumsVisited: visited,
stadiumsRemaining: remaining
)
visitedStadiums = visited
unvisitedStadiums = remaining
// Compute stadium visit status once
var statusMap: [String: StadiumVisitStatus] = [:]
let visitsByStadium = Dictionary(grouping: visits.filter { $0.sportEnum == selectedSport }) { $0.stadiumId }
for stadium in stadiums {
if let stadiumVisits = visitsByStadium[stadium.id], !stadiumVisits.isEmpty {
let summaries = stadiumVisits.map { visit in
VisitSummary(
id: visit.id,
stadium: stadium,
visitDate: visit.visitDate,
visitType: visit.visitType,
sport: selectedSport,
homeTeamName: visit.homeTeamName,
awayTeamName: visit.awayTeamName,
score: visit.finalScore,
photoCount: visit.photoMetadata?.count ?? 0,
notes: visit.notes
)
}
statusMap[stadium.id] = .visited(visits: summaries)
} else {
statusMap[stadium.id] = .notVisited
}
}
stadiumVisitStatus = statusMap
// Pre-compute sport progress fractions for SportSelectorGrid
var uniqueStadiumIdsBySport: [Sport: Set<String>] = [:]
for visit in visits {
if let sport = visit.sportEnum {
uniqueStadiumIdsBySport[sport, default: []].insert(visit.stadiumId)
}
}
sportProgressFractions = Dictionary(uniqueKeysWithValues: Sport.supported.map { sport in
let total = LeagueStructure.stadiumCount(for: sport)
let visited = min(uniqueStadiumIdsBySport[sport]?.count ?? 0, total)
return (sport, total > 0 ? Double(visited) / Double(total) : 0)
})
// Recompute recent visits cache
recomputeRecentVisits()
}
private func recomputeRecentVisits() {
recentVisits = visits
.sorted { $0.visitDate > $1.visitDate }
.prefix(10)
.compactMap { visit -> VisitSummary? in
guard let stadium = dataProvider.stadium(for: visit.stadiumId),
let sport = visit.sportEnum else {
return nil
}
return VisitSummary(
id: visit.id,
stadium: stadium,
visitDate: visit.visitDate,
visitType: visit.visitType,
sport: sport,
homeTeamName: visit.homeTeamName,
awayTeamName: visit.awayTeamName,
score: visit.finalScore,
photoCount: visit.photoMetadata?.count ?? 0,
notes: visit.notes
)
}
}
// MARK: - Progress Card Generation
func progressCardData(includeUsername: Bool = false) -> ProgressCardData {
ProgressCardData(
sport: selectedSport,
progress: leagueProgress,
username: nil,
includeMap: true,
showDetailedStats: false
)
}
}