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

@@ -28,6 +28,7 @@ final class ScheduleViewModel {
private(set) var errorMessage: String?
private let dataProvider = AppDataProvider.shared
private var loadTask: Task<Void, Never>?
// MARK: - Pre-computed Groupings (avoid computed property overhead)
@@ -117,6 +118,7 @@ final class ScheduleViewModel {
logger.info("📅 \(sport.rawValue): \(count) games")
}
#if DEBUG
// Debug: Print all NBA games
let nbaGames = games.filter { $0.game.sport == .nba }
print("🏀 [DEBUG] All NBA games in schedule (\(nbaGames.count) total):")
@@ -124,6 +126,7 @@ final class ScheduleViewModel {
let dateStr = game.game.dateTime.gameDateTimeString(in: game.stadium.timeZone)
print("🏀 \(dateStr): \(game.awayTeam.name) @ \(game.homeTeam.name) (\(game.game.id))")
}
#endif
} catch let cloudKitError as CloudKitError {
self.error = cloudKitError
@@ -151,9 +154,8 @@ final class ScheduleViewModel {
selectedSports.insert(sport)
}
AnalyticsManager.shared.track(.scheduleFiltered(sport: sport.rawValue, dateRange: "\(startDate.formatted(.dateTime.month().day())) - \(endDate.formatted(.dateTime.month().day()))"))
Task {
await loadGames()
}
loadTask?.cancel()
loadTask = Task { await loadGames() }
}
func resetFilters() {
@@ -161,17 +163,15 @@ final class ScheduleViewModel {
searchText = ""
startDate = Date()
endDate = Calendar.current.date(byAdding: .day, value: 14, to: Date()) ?? Date()
Task {
await loadGames()
}
loadTask?.cancel()
loadTask = Task { await loadGames() }
}
func updateDateRange(start: Date, end: Date) {
startDate = start
endDate = end
Task {
await loadGames()
}
loadTask?.cancel()
loadTask = Task { await loadGames() }
}
// MARK: - Filtering & Grouping (pre-computed, not computed properties)

View File

@@ -11,17 +11,17 @@ struct ScheduleListView: View {
@State private var showDatePicker = false
@State private var showDiagnostics = false
private var allGames: [RichGame] {
viewModel.gamesBySport.flatMap(\.games)
private var hasGames: Bool {
!viewModel.gamesBySport.isEmpty
}
var body: some View {
Group {
if viewModel.isLoading && allGames.isEmpty {
if viewModel.isLoading && !hasGames {
loadingView
} else if let errorMessage = viewModel.errorMessage {
errorView(message: errorMessage)
} else if allGames.isEmpty {
} else if !hasGames {
emptyView
} else {
gamesList
@@ -224,6 +224,7 @@ struct SportFilterChip: View {
}
.buttonStyle(.plain)
.accessibilityIdentifier("schedule.sport.\(sport.rawValue.lowercased())")
.accessibilityLabel(sport.rawValue)
.accessibilityValue(isSelected ? "Selected" : "Not selected")
.accessibilityAddTraits(isSelected ? .isSelected : [])
}
@@ -313,6 +314,18 @@ struct GameRowView: View {
}
.padding(.vertical, 4)
.listRowBackground(isToday ? Color.orange.opacity(0.1) : nil)
.accessibilityElement(children: .ignore)
.accessibilityLabel(gameAccessibilityLabel)
}
private var gameAccessibilityLabel: String {
var parts = ["\(game.awayTeam.name) at \(game.homeTeam.name)"]
parts.append(game.stadium.name)
parts.append(game.localGameTimeShort)
if showDate {
parts.append(Self.dateFormatter.string(from: game.game.dateTime))
}
return parts.joined(separator: ", ")
}
}