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

Second audit round addressing data races, task stacking, unbounded
caches, and VoiceOver gaps across 7 files.

Concurrency:
- Move NSItemProvider @State access into MainActor block (3 drop handlers)
- Cancel stale ScheduleViewModel tasks on rapid filter changes

Memory:
- Replace unbounded image dict with LRUCache(countLimit: 50)
- Replace demo-mode asyncAfter with cancellable Task

Performance:
- Wrap debug NBA print() in #if DEBUG
- Cache visitsById via @State + onChange instead of per-render computed
- Pre-compute sportProgressFractions in ProgressViewModel
- Replace allGames computed property with hasGames bool check
- Cache sortedTrips via @State + onChange in SavedTripsListView

Accessibility:
- Add combined VoiceOver label to progress ring
- Combine away/home team text into single readable phrase
- Hide decorative StadiumDetailSheet icon from VoiceOver
- Add explicit accessibilityLabel to SportFilterChip
- Add combined accessibilityLabel to GameRowView

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-02-18 22:30:30 -06:00
parent c32a08a49e
commit d0cbf75fc4
7 changed files with 75 additions and 48 deletions

View File

@@ -19,11 +19,7 @@ struct ProgressTabView: View {
@State private var selectedVisitId: UUID?
@Query private var visits: [StadiumVisit]
/// O(1) lookup for visits by ID (built from @Query results)
private var visitsById: [UUID: StadiumVisit] {
Dictionary(uniqueKeysWithValues: visits.map { ($0.id, $0) })
}
@State private var visitsById: [UUID: StadiumVisit] = [:]
var body: some View {
Group {
@@ -66,6 +62,9 @@ struct ProgressTabView: View {
.accessibilityLabel("Add stadium visit")
}
}
.onChange(of: visits, initial: true) { _, newVisits in
visitsById = Dictionary(uniqueKeysWithValues: newVisits.map { ($0.id, $0) })
}
.task {
viewModel.configure(with: modelContext.container)
await viewModel.loadData()
@@ -153,7 +152,7 @@ struct ProgressTabView: View {
SportProgressButton(
sport: sport,
isSelected: viewModel.selectedSport == sport,
progress: progressForSport(sport)
progress: viewModel.sportProgressFractions[sport] ?? 0
) {
Theme.Animation.withMotion(Theme.Animation.spring) {
viewModel.selectSport(sport)
@@ -162,12 +161,6 @@ struct ProgressTabView: View {
}
}
private func progressForSport(_ sport: Sport) -> Double {
let visitedCount = viewModel.visits.filter { $0.sportEnum == sport }.count
let total = LeagueStructure.stadiumCount(for: sport)
guard total > 0 else { return 0 }
return Double(min(visitedCount, total)) / Double(total)
}
// MARK: - Progress Summary Card
@@ -204,6 +197,8 @@ struct ProgressTabView: View {
.foregroundStyle(Theme.textMuted(colorScheme))
}
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(progress.visitedStadiums) of \(progress.totalStadiums) stadiums visited, \(Int(progress.completionPercentage)) percent complete")
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
Text(viewModel.selectedSport.displayName)
@@ -540,13 +535,10 @@ struct RecentVisitRow: View {
.font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme))
// Date, Away @ Home on one line, left aligned
VStack(alignment: .leading, spacing: 4) {
Text(visit.shortDateDescription)
if let away = visit.awayTeamName, let home = visit.homeTeamName {
Text(away)
Text("@")
Text(home)
Text("\(away) at \(home)")
}
}
.font(.subheadline)
@@ -596,6 +588,7 @@ struct StadiumDetailSheet: View {
.font(.largeTitle)
.foregroundStyle(visitStatus.isVisited ? .green : sport.themeColor)
}
.accessibilityHidden(true)
Text(stadium.name)
.font(.headline)