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

@@ -35,6 +35,7 @@ struct TripDetailView: View {
@State private var loadedGames: [String: RichGame] = [:]
@State private var isLoadingGames = false
@State private var hasAppliedDemoSelection = false
@State private var demoSaveTask: Task<Void, Never>?
// Itinerary items state
@State private var itineraryItems: [ItineraryItem] = []
@@ -122,10 +123,10 @@ struct TripDetailView: View {
// Demo mode: auto-favorite the trip
if isDemoMode && !hasAppliedDemoSelection && !isSaved {
hasAppliedDemoSelection = true
DispatchQueue.main.asyncAfter(deadline: .now() + DemoConfig.selectionDelay + 0.5) {
if !isSaved {
saveTrip()
}
demoSaveTask = Task {
try? await Task.sleep(for: .seconds(DemoConfig.selectionDelay + 0.5))
guard !Task.isCancelled, !isSaved else { return }
saveTrip()
}
}
}
@@ -137,7 +138,10 @@ struct TripDetailView: View {
}
recomputeSections()
}
.onDisappear { subscriptionCancellable?.cancel() }
.onDisappear {
subscriptionCancellable?.cancel()
demoSaveTask?.cancel()
}
.onChange(of: itineraryItems) { _, newItems in
handleItineraryItemsChange(newItems)
recomputeSections()
@@ -715,10 +719,10 @@ struct TripDetailView: View {
provider.loadObject(ofClass: NSString.self) { item, _ in
guard let droppedId = item as? String,
let itemId = UUID(uuidString: droppedId),
let droppedItem = self.itineraryItems.first(where: { $0.id == itemId }) else { return }
let itemId = UUID(uuidString: droppedId) else { return }
Task { @MainActor in
guard let droppedItem = self.itineraryItems.first(where: { $0.id == itemId }) else { return }
let day = self.findDayForTravelSegment(segment)
// Place at beginning of day (sortOrder before existing items)
let minSortOrder = self.itineraryItems
@@ -743,11 +747,11 @@ struct TripDetailView: View {
provider.loadObject(ofClass: NSString.self) { item, _ in
guard let droppedId = item as? String,
let itemId = UUID(uuidString: droppedId),
let droppedItem = self.itineraryItems.first(where: { $0.id == itemId }),
droppedItem.id != targetItem.id else { return }
let itemId = UUID(uuidString: droppedId) else { return }
Task { @MainActor in
guard let droppedItem = self.itineraryItems.first(where: { $0.id == itemId }),
droppedItem.id != targetItem.id else { return }
// Place before target item using midpoint insertion
let itemsInDay = self.itineraryItems.filter { $0.day == targetItem.day && $0.id != droppedItem.id }
.sorted { $0.sortOrder < $1.sortOrder }
@@ -772,10 +776,10 @@ struct TripDetailView: View {
provider.loadObject(ofClass: NSString.self) { item, _ in
guard let droppedId = item as? String,
let itemId = UUID(uuidString: droppedId),
let droppedItem = self.itineraryItems.first(where: { $0.id == itemId }) else { return }
let itemId = UUID(uuidString: droppedId) else { return }
Task { @MainActor in
guard let droppedItem = self.itineraryItems.first(where: { $0.id == itemId }) else { return }
// Calculate sortOrder: append at end of day's items
let maxSortOrder = self.itineraryItems
.filter { $0.day == day && $0.id != droppedItem.id }