fix: 10 audit fixes — memory safety, performance, accessibility, architecture

- Add a11y label to ProgressMapView reset button and progress bar values
- Fix CADisplayLink retain cycle in ItineraryTableViewController via deinit
- Add [weak self] to PhotoGalleryViewModel Task closure
- Add @MainActor to TripWizardViewModel, remove manual MainActor.run hop
- Fix O(n²) rank lookup in PollDetailView/DebugPollPreviewView with enumerated()
- Cache itinerarySections via ItinerarySectionBuilder static extraction + @State
- Convert CanonicalSyncService/BootstrapService from actor to @MainActor final class
- Add .accessibilityHidden(true) to RegionMapSelector Map to prevent duplicate VoiceOver

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-02-17 12:00:35 -06:00
parent 46434af4ab
commit 9b0cb96638
13 changed files with 415 additions and 109 deletions

View File

@@ -276,9 +276,9 @@ struct PollDetailView: View {
}
VStack(spacing: Theme.Spacing.sm) {
ForEach(results.tripScores, id: \.tripIndex) { item in
ForEach(Array(results.tripScores.enumerated()), id: \.element.tripIndex) { index, item in
let trip = results.poll.tripSnapshots[item.tripIndex]
let rank = results.tripScores.firstIndex { $0.tripIndex == item.tripIndex }! + 1
let rank = index + 1
ResultRow(
rank: rank,
tripName: trip.displayName,

View File

@@ -366,6 +366,7 @@ struct AchievementCard: View {
VStack(spacing: 4) {
ProgressView(value: achievement.progressPercentage)
.progressViewStyle(AchievementProgressStyle(category: achievement.definition.category))
.accessibilityValue("\(Int(achievement.progressPercentage * 100)) percent, \(achievement.progressText)")
Text(achievement.progressText)
.font(.caption)
@@ -575,6 +576,7 @@ struct AchievementDetailSheet: View {
ProgressView(value: achievement.progressPercentage)
.progressViewStyle(LargeProgressStyle())
.frame(width: 200)
.accessibilityValue("\(Int(achievement.progressPercentage * 100)) percent, \(achievement.currentProgress) of \(achievement.totalRequired)")
Text("\(achievement.currentProgress) / \(achievement.totalRequired)")
.font(.headline)

View File

@@ -62,6 +62,7 @@ struct ProgressMapView: View {
.padding(12)
.background(.regularMaterial, in: Circle())
}
.accessibilityLabel("Reset map view")
.padding()
}
}

View File

@@ -189,9 +189,9 @@ struct DebugPollPreviewView: View {
}
VStack(spacing: Theme.Spacing.sm) {
ForEach(results.tripScores, id: \.tripIndex) { item in
ForEach(Array(results.tripScores.enumerated()), id: \.element.tripIndex) { index, item in
let trip = results.poll.tripSnapshots[item.tripIndex]
let rank = results.tripScores.firstIndex { $0.tripIndex == item.tripIndex }! + 1
let rank = index + 1
DebugResultRow(
rank: rank,
tripName: trip.displayName,

View File

@@ -8,7 +8,7 @@
import Foundation
import SwiftUI
@Observable
@MainActor @Observable
final class TripWizardViewModel {
// MARK: - Planning Mode
@@ -186,9 +186,7 @@ final class TripWizardViewModel {
}
}
await MainActor.run {
self.sportAvailability = availability
}
self.sportAvailability = availability
}
// MARK: - Reset Logic

View File

@@ -0,0 +1,148 @@
//
// ItinerarySectionBuilder.swift
// SportsTime
//
// Pure static builder for itinerary sections.
// Extracted from TripDetailView to enable caching and testability.
//
import Foundation
enum ItinerarySectionBuilder {
/// Build itinerary sections from trip data.
///
/// This is a pure function: same inputs always produce the same output.
/// Callers should cache the result and recompute only when inputs change.
static func build(
trip: Trip,
tripDays: [Date],
games: [String: RichGame],
travelOverrides: [String: TravelOverride],
itineraryItems: [ItineraryItem],
allowCustomItems: Bool
) -> [ItinerarySection] {
var sections: [ItinerarySection] = []
// Use TravelPlacement for consistent day calculation (shared with tests).
var travelByDay = TravelPlacement.computeTravelByDay(trip: trip, tripDays: tripDays)
// Apply user overrides on top of computed defaults.
for (segmentIndex, segment) in trip.travelSegments.enumerated() {
let travelId = stableTravelAnchorId(segment, at: segmentIndex)
guard let override = travelOverrides[travelId] else { continue }
// Validate override is within valid day range
if let validRange = validDayRange(for: travelId, trip: trip, tripDays: tripDays),
validRange.contains(override.day) {
// Remove from computed position (search all days)
for key in travelByDay.keys {
travelByDay[key]?.removeAll { $0.id == segment.id }
}
travelByDay.keys.forEach { key in
if travelByDay[key]?.isEmpty == true { travelByDay[key] = nil }
}
// Place at overridden position
travelByDay[override.day, default: []].append(segment)
}
}
// Process ALL days
for (index, dayDate) in tripDays.enumerated() {
let dayNum = index + 1
let gamesOnDay = gamesOn(date: dayDate, trip: trip, games: games)
// Travel for this day (if any) - appears before day header
for travelSegment in (travelByDay[dayNum] ?? []) {
let segIdx = trip.travelSegments.firstIndex(where: { $0.id == travelSegment.id }) ?? 0
sections.append(.travel(travelSegment, segmentIndex: segIdx))
}
// Day section - shows games or minimal rest day display
sections.append(.day(dayNumber: dayNum, date: dayDate, games: gamesOnDay))
// Custom items for this day (sorted by sortOrder)
if allowCustomItems {
// Add button first - always right after day header
sections.append(.addButton(day: dayNum))
let dayItems = itineraryItems
.filter { $0.day == dayNum && $0.isCustom }
.sorted { $0.sortOrder < $1.sortOrder }
for item in dayItems {
sections.append(.customItem(item))
}
}
}
return sections
}
// MARK: - Helpers
static func stableTravelAnchorId(_ segment: TravelSegment, at index: Int) -> String {
let from = TravelInfo.normalizeCityName(segment.fromLocation.name)
let to = TravelInfo.normalizeCityName(segment.toLocation.name)
return "travel:\(index):\(from)->\(to)"
}
static func gamesOn(date: Date, trip: Trip, games: [String: RichGame]) -> [RichGame] {
let calendar = Calendar.current
let dayStart = calendar.startOfDay(for: date)
let allGameIds = trip.stops.flatMap { $0.games }
let foundGames = allGameIds.compactMap { games[$0] }
return foundGames.filter { richGame in
calendar.startOfDay(for: richGame.game.dateTime) == dayStart
}.sorted { $0.game.dateTime < $1.game.dateTime }
}
/// Parse the segment index from a travel anchor ID.
/// Format: "travel:INDEX:from->to" INDEX
private static func parseSegmentIndex(from travelId: String) -> Int? {
let stripped = travelId.replacingOccurrences(of: "travel:", with: "")
let parts = stripped.components(separatedBy: ":")
guard parts.count >= 2, let index = Int(parts[0]) else { return nil }
return index
}
private static func validDayRange(for travelId: String, trip: Trip, tripDays: [Date]) -> ClosedRange<Int>? {
guard let segmentIndex = parseSegmentIndex(from: travelId),
segmentIndex < trip.stops.count - 1 else {
return nil
}
let fromStop = trip.stops[segmentIndex]
let toStop = trip.stops[segmentIndex + 1]
let fromDayNum = dayNumber(for: fromStop.departureDate, tripDays: tripDays)
let toDayNum = dayNumber(for: toStop.arrivalDate, tripDays: tripDays)
let minDay = max(fromDayNum + 1, 1)
let maxDay = min(toDayNum, tripDays.count)
if minDay > maxDay {
return nil
}
return minDay...maxDay
}
private static func dayNumber(for date: Date, tripDays: [Date]) -> Int {
let calendar = Calendar.current
let target = calendar.startOfDay(for: date)
for (index, tripDay) in tripDays.enumerated() {
if calendar.startOfDay(for: tripDay) == target {
return index + 1
}
}
// If date is before trip, return 0; if after, return count + 1
if let first = tripDays.first, target < calendar.startOfDay(for: first) {
return 0
}
return tripDays.count + 1
}
}

View File

@@ -511,6 +511,13 @@ final class ItineraryTableViewController: UITableViewController {
ItineraryReorderingLogic.travelRow(in: flatItems, forDay: day)
}
deinit {
#if DEBUG
displayLink?.invalidate()
displayLink = nil
#endif
}
// MARK: - Marketing Video Auto-Scroll
#if DEBUG

View File

@@ -54,6 +54,7 @@ struct RegionMapSelector: View {
.stroke(strokeColor(for: .east), lineWidth: strokeWidth(for: .east))
}
.mapStyle(.standard(elevation: .flat, pointsOfInterest: .excludingAll))
.accessibilityHidden(true)
.onTapGesture { location in
if let coordinate = proxy.convert(location, from: .local) {
let tappedRegion = Self.regionForCoordinate(coordinate)

View File

@@ -45,6 +45,7 @@ struct TripDetailView: View {
@State private var draggedTravelId: String? // Track which travel segment is being dragged
@State private var dropTargetId: String? // Track which drop zone is being hovered
@State private var travelOverrides: [String: TravelOverride] = [:] // Key: travel ID, Value: day + sortOrder
@State private var cachedSections: [ItinerarySection] = []
// Apple Maps state
@State private var showMultiRouteAlert = false
@@ -134,14 +135,20 @@ struct TripDetailView: View {
await loadItineraryItems()
await setupSubscription()
}
recomputeSections()
}
.onDisappear { subscriptionCancellable?.cancel() }
.onChange(of: itineraryItems) { _, newItems in
handleItineraryItemsChange(newItems)
recomputeSections()
}
.onChange(of: travelOverrides.count) { _, _ in
draggedTravelId = nil
dropTargetId = nil
recomputeSections()
}
.onChange(of: loadedGames.count) { _, _ in
recomputeSections()
}
.overlay {
if isExporting { exportProgressOverlay }
@@ -536,7 +543,7 @@ struct TripDetailView: View {
.frame(maxHeight: .infinity)
VStack(spacing: Theme.Spacing.md) {
ForEach(Array(itinerarySections.enumerated()), id: \.offset) { index, section in
ForEach(Array(cachedSections.enumerated()), id: \.offset) { index, section in
itineraryRow(for: section, at: index)
}
}
@@ -801,11 +808,11 @@ struct TripDetailView: View {
private func findDayForTravelSegment(_ segment: TravelSegment) -> Int {
// Find which day this travel segment belongs to by looking at sections
// Travel appears BEFORE the arrival day, so look FORWARD to find arrival day
for (index, section) in itinerarySections.enumerated() {
for (index, section) in cachedSections.enumerated() {
if case .travel(let s, _) = section, s.id == segment.id {
// Look forward to find the arrival day
for i in (index + 1)..<itinerarySections.count {
if case .day(let dayNumber, _, _) = itinerarySections[i] {
for i in (index + 1)..<cachedSections.count {
if case .day(let dayNumber, _, _) = cachedSections[i] {
return dayNumber
}
}
@@ -816,9 +823,7 @@ struct TripDetailView: View {
/// Create a stable anchor ID for a travel segment (UUIDs regenerate on reload)
private func stableTravelAnchorId(_ segment: TravelSegment, at index: Int) -> String {
let from = TravelInfo.normalizeCityName(segment.fromLocation.name)
let to = TravelInfo.normalizeCityName(segment.toLocation.name)
return "travel:\(index):\(from)->\(to)"
ItinerarySectionBuilder.stableTravelAnchorId(segment, at: index)
}
/// Move item to a new day and sortOrder position
@@ -841,63 +846,16 @@ struct TripDetailView: View {
print("✅ [Move] Synced \(title) with day: \(day), sortOrder: \(sortOrder)")
}
/// Build itinerary sections: shows ALL days with travel, custom items, and add buttons
private var itinerarySections: [ItinerarySection] {
var sections: [ItinerarySection] = []
let days = tripDays
// Use TravelPlacement for consistent day calculation (shared with tests).
var travelByDay = TravelPlacement.computeTravelByDay(trip: trip, tripDays: days)
// Apply user overrides on top of computed defaults.
for (segmentIndex, segment) in trip.travelSegments.enumerated() {
let travelId = stableTravelAnchorId(segment, at: segmentIndex)
guard let override = travelOverrides[travelId] else { continue }
// Validate override is within valid day range
if let validRange = validDayRange(for: travelId),
validRange.contains(override.day) {
// Remove from computed position (search all days)
for key in travelByDay.keys {
travelByDay[key]?.removeAll { $0.id == segment.id }
}
travelByDay.keys.forEach { key in
if travelByDay[key]?.isEmpty == true { travelByDay[key] = nil }
}
// Place at overridden position
travelByDay[override.day, default: []].append(segment)
}
}
// Process ALL days
for (index, dayDate) in days.enumerated() {
let dayNum = index + 1
let gamesOnDay = gamesOn(date: dayDate)
// Travel for this day (if any) - appears before day header
for travelSegment in (travelByDay[dayNum] ?? []) {
let segIdx = trip.travelSegments.firstIndex(where: { $0.id == travelSegment.id }) ?? 0
sections.append(.travel(travelSegment, segmentIndex: segIdx))
}
// Day section - shows games or minimal rest day display
sections.append(.day(dayNumber: dayNum, date: dayDate, games: gamesOnDay))
// Custom items for this day (sorted by sortOrder)
if allowCustomItems {
// Add button first - always right after day header
sections.append(.addButton(day: dayNum))
let dayItems = itineraryItems
.filter { $0.day == dayNum && $0.isCustom }
.sorted { $0.sortOrder < $1.sortOrder }
for item in dayItems {
sections.append(.customItem(item))
}
}
}
return sections
/// Recompute cached itinerary sections from current state.
private func recomputeSections() {
cachedSections = ItinerarySectionBuilder.build(
trip: trip,
tripDays: tripDays,
games: games,
travelOverrides: travelOverrides,
itineraryItems: itineraryItems,
allowCustomItems: allowCustomItems
)
}
private var tripDays: [Date] {