fix: 13 audit fixes — memory, concurrency, performance, accessibility

Critical:
- ProgressViewModel: use single stored ModelContext instead of creating
  new ones per operation (deleteVisit silently no-op'd)
- ProgressViewModel: convert expensive computed properties to stored
  with explicit recompute after mutations (3x recomputation per render)

Memory:
- AnimatedSportsIcon: replace recursive GCD asyncAfter with Task loop,
  cancelled in onDisappear (19 unkillable timer chains)
- ItineraryItemService: remove [weak self] from actor Task (semantically
  wrong, silently drops flushPendingUpdates)
- VisitPhotoService: remove [weak self] from @MainActor Task closures

Concurrency:
- StoreManager: replace nested MainActor.run{Task{}} with direct await
  in listenForTransactions (fire-and-forget race)
- VisitPhotoService: move JPEG encoding/file writing off MainActor via
  nonisolated static helper + Task.detached
- SportsIconImageGenerator: replace GCD dispatch with Task.detached for
  structured concurrency compliance

Performance:
- Game/RichGame: cache DateFormatters as static lets instead of
  allocating per-call (hundreds of allocations in schedule view)
- TripDetailView: wrap ~10 routeWaypoints print() in #if DEBUG, remove
  2 let _ = print() from TripMapView.body (fires every render)

Accessibility:
- GameRow: add combined VoiceOver label (was reading abbreviations
  letter-by-letter)
- Sport badges: add accessibilityLabel to prevent SF symbol name readout
- SportsTimeApp: post UIAccessibility.screenChanged after bootstrap
  completes so VoiceOver users know app is ready

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-02-18 22:09:06 -06:00
parent 20ac1a7e59
commit 5511e07538
9 changed files with 196 additions and 185 deletions

View File

@@ -29,83 +29,28 @@ final class ProgressViewModel {
// MARK: - Dependencies
private var modelContainer: ModelContainer?
private var modelContext: ModelContext?
private let dataProvider = AppDataProvider.shared
// MARK: - Computed Properties
// MARK: - Derived State (recomputed after mutations)
/// Overall progress for the selected sport
var leagueProgress: LeagueProgress {
// Filter stadiums by sport directly (same as sportStadiums)
let sportStadiums = stadiums.filter { $0.sport == selectedSport }
let visitedStadiumIds = Set(
visits
.filter { $0.sportEnum == selectedSport }
.compactMap { visit -> String? in
// O(1) dictionary lookup via DataProvider
dataProvider.stadium(for: visit.stadiumId)?.id
}
)
let visited = sportStadiums.filter { visitedStadiumIds.contains($0.id) }
let remaining = sportStadiums.filter { !visitedStadiumIds.contains($0.id) }
return LeagueProgress(
sport: selectedSport,
totalStadiums: sportStadiums.count,
visitedStadiums: visited.count,
stadiumsVisited: visited,
stadiumsRemaining: remaining
)
}
private(set) var leagueProgress: LeagueProgress = LeagueProgress(sport: .mlb, totalStadiums: 0, visitedStadiums: 0, stadiumsVisited: [], stadiumsRemaining: [])
/// Stadium visit status indexed by stadium ID
var stadiumVisitStatus: [String: StadiumVisitStatus] {
var statusMap: [String: StadiumVisitStatus] = [:]
private(set) var stadiumVisitStatus: [String: StadiumVisitStatus] = [:]
// Group visits by stadium
let visitsByStadium = Dictionary(grouping: visits.filter { $0.sportEnum == selectedSport }) { $0.stadiumId }
/// Visited stadiums for the selected sport
private(set) var visitedStadiums: [Stadium] = []
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
}
}
return statusMap
}
/// Unvisited stadiums for the selected sport
private(set) var unvisitedStadiums: [Stadium] = []
/// Stadiums for the selected sport
var sportStadiums: [Stadium] {
stadiums.filter { $0.sport == selectedSport }
}
/// Visited stadiums for the selected sport
var visitedStadiums: [Stadium] {
leagueProgress.stadiumsVisited
}
/// Unvisited stadiums for the selected sport
var unvisitedStadiums: [Stadium] {
leagueProgress.stadiumsRemaining
}
/// Count of trips for the selected sport (stub - can be enhanced)
var tripCount: Int {
// TODO: Fetch saved trips count from SwiftData
@@ -141,6 +86,7 @@ final class ProgressViewModel {
func configure(with container: ModelContainer) {
self.modelContainer = container
self.modelContext = ModelContext(container)
}
// MARK: - Actions
@@ -159,8 +105,7 @@ final class ProgressViewModel {
teams = dataProvider.teams
// Load visits from SwiftData
if let container = modelContainer {
let context = ModelContext(container)
if let context = modelContext {
let descriptor = FetchDescriptor<StadiumVisit>(
sortBy: [SortDescriptor(\.visitDate, order: .reverse)]
)
@@ -171,11 +116,13 @@ final class ProgressViewModel {
self.errorMessage = error.localizedDescription
}
recomputeDerivedState()
isLoading = false
}
func selectSport(_ sport: Sport) {
selectedSport = sport
recomputeDerivedState()
AnalyticsManager.shared.track(.sportSwitched(sport: sport.rawValue))
}
@@ -187,13 +134,12 @@ final class ProgressViewModel {
// MARK: - Visit Management
func deleteVisit(_ visit: StadiumVisit) async throws {
guard let container = modelContainer else { return }
guard let context = modelContext else { return }
if let sport = visit.sportEnum {
AnalyticsManager.shared.track(.stadiumVisitDeleted(stadiumId: visit.stadiumId, sport: sport.rawValue))
}
let context = ModelContext(container)
context.delete(visit)
try context.save()
@@ -201,6 +147,61 @@ final class ProgressViewModel {
await loadData()
}
// MARK: - Derived State Recomputation
private func recomputeDerivedState() {
// Compute league progress once
let sportStadiums = stadiums.filter { $0.sport == selectedSport }
let visitedStadiumIds = Set(
visits
.filter { $0.sportEnum == selectedSport }
.compactMap { visit -> String? in
dataProvider.stadium(for: visit.stadiumId)?.id
}
)
let visited = sportStadiums.filter { visitedStadiumIds.contains($0.id) }
let remaining = sportStadiums.filter { !visitedStadiumIds.contains($0.id) }
leagueProgress = LeagueProgress(
sport: selectedSport,
totalStadiums: sportStadiums.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
}
// MARK: - Progress Card Generation
func progressCardData(includeUsername: Bool = false) -> ProgressCardData {