diff --git a/SportsTime/Core/Services/BackgroundSyncManager.swift b/SportsTime/Core/Services/BackgroundSyncManager.swift index f00d661..c8b0019 100644 --- a/SportsTime/Core/Services/BackgroundSyncManager.swift +++ b/SportsTime/Core/Services/BackgroundSyncManager.swift @@ -63,10 +63,10 @@ final class BackgroundSyncManager { BGTaskScheduler.shared.register( forTaskWithIdentifier: Self.refreshTaskIdentifier, using: nil - ) { task in + ) { [weak self] task in guard let task = task as? BGAppRefreshTask else { return } Task { @MainActor in - await self.handleRefreshTask(task) + await self?.handleRefreshTask(task) } } @@ -74,10 +74,10 @@ final class BackgroundSyncManager { BGTaskScheduler.shared.register( forTaskWithIdentifier: Self.processingTaskIdentifier, using: nil - ) { task in + ) { [weak self] task in guard let task = task as? BGProcessingTask else { return } Task { @MainActor in - await self.handleProcessingTask(task) + await self?.handleProcessingTask(task) } } diff --git a/SportsTime/Core/Services/BootstrapService.swift b/SportsTime/Core/Services/BootstrapService.swift index 8da3819..2769ad2 100644 --- a/SportsTime/Core/Services/BootstrapService.swift +++ b/SportsTime/Core/Services/BootstrapService.swift @@ -202,12 +202,13 @@ final class BootstrapService { throw BootstrapError.bundledResourceNotFound("stadiums_canonical.json") } - let data: Data let stadiums: [JSONCanonicalStadium] do { - data = try Data(contentsOf: url) - stadiums = try JSONDecoder().decode([JSONCanonicalStadium].self, from: data) + stadiums = try await Task.detached { + let data = try Data(contentsOf: url) + return try JSONDecoder().decode([JSONCanonicalStadium].self, from: data) + }.value } catch { throw BootstrapError.jsonDecodingFailed("stadiums_canonical.json", error) } @@ -238,12 +239,13 @@ final class BootstrapService { return } - let data: Data let aliases: [JSONStadiumAlias] do { - data = try Data(contentsOf: url) - aliases = try JSONDecoder().decode([JSONStadiumAlias].self, from: data) + aliases = try await Task.detached { + let data = try Data(contentsOf: url) + return try JSONDecoder().decode([JSONStadiumAlias].self, from: data) + }.value } catch { throw BootstrapError.jsonDecodingFailed("stadium_aliases.json", error) } @@ -280,12 +282,13 @@ final class BootstrapService { return } - let data: Data let structures: [JSONLeagueStructure] do { - data = try Data(contentsOf: url) - structures = try JSONDecoder().decode([JSONLeagueStructure].self, from: data) + structures = try await Task.detached { + let data = try Data(contentsOf: url) + return try JSONDecoder().decode([JSONLeagueStructure].self, from: data) + }.value } catch { throw BootstrapError.jsonDecodingFailed("league_structure.json", error) } @@ -319,12 +322,13 @@ final class BootstrapService { throw BootstrapError.bundledResourceNotFound("teams_canonical.json") } - let data: Data let teams: [JSONCanonicalTeam] do { - data = try Data(contentsOf: url) - teams = try JSONDecoder().decode([JSONCanonicalTeam].self, from: data) + teams = try await Task.detached { + let data = try Data(contentsOf: url) + return try JSONDecoder().decode([JSONCanonicalTeam].self, from: data) + }.value } catch { throw BootstrapError.jsonDecodingFailed("teams_canonical.json", error) } @@ -352,12 +356,13 @@ final class BootstrapService { return } - let data: Data let aliases: [JSONTeamAlias] do { - data = try Data(contentsOf: url) - aliases = try JSONDecoder().decode([JSONTeamAlias].self, from: data) + aliases = try await Task.detached { + let data = try Data(contentsOf: url) + return try JSONDecoder().decode([JSONTeamAlias].self, from: data) + }.value } catch { throw BootstrapError.jsonDecodingFailed("team_aliases.json", error) } @@ -393,12 +398,13 @@ final class BootstrapService { throw BootstrapError.bundledResourceNotFound("games_canonical.json") } - let data: Data let games: [JSONCanonicalGame] do { - data = try Data(contentsOf: url) - games = try JSONDecoder().decode([JSONCanonicalGame].self, from: data) + games = try await Task.detached { + let data = try Data(contentsOf: url) + return try JSONDecoder().decode([JSONCanonicalGame].self, from: data) + }.value } catch { throw BootstrapError.jsonDecodingFailed("games_canonical.json", error) } @@ -462,12 +468,13 @@ final class BootstrapService { return } - let data: Data let sports: [JSONCanonicalSport] do { - data = try Data(contentsOf: url) - sports = try JSONDecoder().decode([JSONCanonicalSport].self, from: data) + sports = try await Task.detached { + let data = try Data(contentsOf: url) + return try JSONDecoder().decode([JSONCanonicalSport].self, from: data) + }.value } catch { throw BootstrapError.jsonDecodingFailed("sports_canonical.json", error) } diff --git a/SportsTime/Core/Services/CanonicalSyncService.swift b/SportsTime/Core/Services/CanonicalSyncService.swift index e3a14a1..79f8b30 100644 --- a/SportsTime/Core/Services/CanonicalSyncService.swift +++ b/SportsTime/Core/Services/CanonicalSyncService.swift @@ -429,11 +429,16 @@ final class CanonicalSyncService { var skippedIncompatible = 0 var skippedOlder = 0 + // Batch-fetch all existing stadiums to avoid N+1 FetchDescriptor lookups + let allExistingStadiums = try context.fetch(FetchDescriptor()) + let existingStadiumsByCanonicalId = Dictionary(grouping: allExistingStadiums, by: \.canonicalId).compactMapValues(\.first) + for syncStadium in syncStadiums { // Use canonical ID directly from CloudKit - no UUID-based generation! let result = try mergeStadium( syncStadium.stadium, canonicalId: syncStadium.canonicalId, + existingRecord: existingStadiumsByCanonicalId[syncStadium.canonicalId], context: context ) @@ -465,6 +470,10 @@ final class CanonicalSyncService { var skippedIncompatible = 0 var skippedOlder = 0 + // Batch-fetch all existing teams to avoid N+1 FetchDescriptor lookups + let allExistingTeams = try context.fetch(FetchDescriptor()) + let existingTeamsByCanonicalId = Dictionary(grouping: allExistingTeams, by: \.canonicalId).compactMapValues(\.first) + for syncTeam in allSyncTeams { // Use canonical IDs directly from CloudKit - no UUID lookups! let result = try mergeTeam( @@ -472,6 +481,7 @@ final class CanonicalSyncService { canonicalId: syncTeam.canonicalId, stadiumCanonicalId: syncTeam.stadiumCanonicalId, validStadiumIds: &validStadiumIds, + existingRecord: existingTeamsByCanonicalId[syncTeam.canonicalId], context: context ) @@ -514,6 +524,10 @@ final class CanonicalSyncService { var skippedIncompatible = 0 var skippedOlder = 0 + // Batch-fetch all existing games to avoid N+1 FetchDescriptor lookups + let allExistingGames = try context.fetch(FetchDescriptor()) + let existingGamesByCanonicalId = Dictionary(grouping: allExistingGames, by: \.canonicalId).compactMapValues(\.first) + for syncGame in syncGames { // Use canonical IDs directly from CloudKit - no UUID lookups! let result = try mergeGame( @@ -525,6 +539,7 @@ final class CanonicalSyncService { validTeamIds: validTeamIds, teamStadiumByTeamId: teamStadiumByTeamId, validStadiumIds: &validStadiumIds, + existingRecord: existingGamesByCanonicalId[syncGame.canonicalId], context: context ) @@ -649,13 +664,19 @@ final class CanonicalSyncService { private func mergeStadium( _ remote: Stadium, canonicalId: String, + existingRecord: CanonicalStadium? = nil, context: ModelContext ) throws -> MergeResult { - // Look up existing - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.canonicalId == canonicalId } - ) - let existing = try context.fetch(descriptor).first + // Use pre-fetched record if available, otherwise fall back to individual fetch + let existing: CanonicalStadium? + if let existingRecord { + existing = existingRecord + } else { + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.canonicalId == canonicalId } + ) + existing = try context.fetch(descriptor).first + } if let existing = existing { // Preserve user fields @@ -715,12 +736,19 @@ final class CanonicalSyncService { canonicalId: String, stadiumCanonicalId: String?, validStadiumIds: inout Set, + existingRecord: CanonicalTeam? = nil, context: ModelContext ) throws -> MergeResult { - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.canonicalId == canonicalId } - ) - let existing = try context.fetch(descriptor).first + // Use pre-fetched record if available, otherwise fall back to individual fetch + let existing: CanonicalTeam? + if let existingRecord { + existing = existingRecord + } else { + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.canonicalId == canonicalId } + ) + existing = try context.fetch(descriptor).first + } let remoteStadiumId = stadiumCanonicalId?.trimmingCharacters(in: .whitespacesAndNewlines) let existingStadiumId = existing?.stadiumCanonicalId.trimmingCharacters(in: .whitespacesAndNewlines) @@ -809,12 +837,19 @@ final class CanonicalSyncService { validTeamIds: Set, teamStadiumByTeamId: [String: String], validStadiumIds: inout Set, + existingRecord: CanonicalGame? = nil, context: ModelContext ) throws -> MergeResult { - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.canonicalId == canonicalId } - ) - let existing = try context.fetch(descriptor).first + // Use pre-fetched record if available, otherwise fall back to individual fetch + let existing: CanonicalGame? + if let existingRecord { + existing = existingRecord + } else { + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.canonicalId == canonicalId } + ) + existing = try context.fetch(descriptor).first + } func resolveTeamId(remote: String, existing: String?) -> String? { if validTeamIds.contains(remote) { diff --git a/SportsTime/Core/Services/DataProvider.swift b/SportsTime/Core/Services/DataProvider.swift index 7e6538a..c45d0fe 100644 --- a/SportsTime/Core/Services/DataProvider.swift +++ b/SportsTime/Core/Services/DataProvider.swift @@ -289,20 +289,25 @@ final class AppDataProvider: ObservableObject { throw DataProviderError.contextNotConfigured } - // Fetch all non-deprecated games (predicate with captured vars causes type-check timeout) - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.deprecatedAt == nil }, + // Use two separate predicates to avoid the type-check timeout from combining + // captured vars with OR in a single #Predicate expression. + let homeDescriptor = FetchDescriptor( + predicate: #Predicate { $0.deprecatedAt == nil && $0.homeTeamCanonicalId == teamId }, + sortBy: [SortDescriptor(\.dateTime)] + ) + let awayDescriptor = FetchDescriptor( + predicate: #Predicate { $0.deprecatedAt == nil && $0.awayTeamCanonicalId == teamId }, sortBy: [SortDescriptor(\.dateTime)] ) - let allCanonical: [CanonicalGame] = try context.fetch(descriptor) + let homeGames = try context.fetch(homeDescriptor) + let awayGames = try context.fetch(awayDescriptor) - // Filter by team in Swift (fast for ~5K games) + // Merge and deduplicate + var seenIds = Set() var teamGames: [RichGame] = [] - for canonical in allCanonical { - guard canonical.homeTeamCanonicalId == teamId || canonical.awayTeamCanonicalId == teamId else { - continue - } + for canonical in homeGames + awayGames { + guard seenIds.insert(canonical.canonicalId).inserted else { continue } let game = canonical.toDomain() guard let homeTeam = teamsById[game.homeTeamId], let awayTeam = teamsById[game.awayTeamId], @@ -311,7 +316,7 @@ final class AppDataProvider: ObservableObject { } teamGames.append(RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)) } - return teamGames + return teamGames.sorted { $0.game.dateTime < $1.game.dateTime } } // Resolve stadium defensively: direct game reference first, then team home-venue fallbacks. diff --git a/SportsTime/Core/Services/LocationService.swift b/SportsTime/Core/Services/LocationService.swift index 377e683..1776608 100644 --- a/SportsTime/Core/Services/LocationService.swift +++ b/SportsTime/Core/Services/LocationService.swift @@ -160,10 +160,10 @@ actor LocationService { // MARK: - Route Info -struct RouteInfo: @unchecked Sendable { +struct RouteInfo: Sendable { let distance: CLLocationDistance // meters let expectedTravelTime: TimeInterval // seconds - let polyline: MKPolyline? + nonisolated(unsafe) let polyline: MKPolyline? var distanceMiles: Double { distance * 0.000621371 } var travelTimeHours: Double { expectedTravelTime / 3600.0 } diff --git a/SportsTime/Core/Services/NetworkMonitor.swift b/SportsTime/Core/Services/NetworkMonitor.swift index fb0ae47..b5dc318 100644 --- a/SportsTime/Core/Services/NetworkMonitor.swift +++ b/SportsTime/Core/Services/NetworkMonitor.swift @@ -32,7 +32,8 @@ final class NetworkMonitor { /// Debounce delay in seconds (2.5s to handle WiFi↔cellular handoffs) private let debounceDelay: TimeInterval = 2.5 - /// Callback for when sync should be triggered + /// Callback for when sync should be triggered after network restoration. + /// - Important: Capture references weakly in this closure to avoid retain cycles. var onSyncNeeded: (() async -> Void)? // MARK: - Singleton diff --git a/SportsTime/Features/Paywall/Views/OnboardingPaywallView.swift b/SportsTime/Features/Paywall/Views/OnboardingPaywallView.swift index 9e4f7a7..d781b19 100644 --- a/SportsTime/Features/Paywall/Views/OnboardingPaywallView.swift +++ b/SportsTime/Features/Paywall/Views/OnboardingPaywallView.swift @@ -351,11 +351,11 @@ struct StadiumMapBackground: View { GeometryReader { geo in HStack(spacing: 4) { Text("6") - .font(.system(size: 18, weight: .bold, design: .rounded)) + .font(.title3.bold()) Text("/") - .font(.system(size: 14)) + .font(.subheadline) Text("12") - .font(.system(size: 14, weight: .medium, design: .rounded)) + .font(.subheadline.weight(.medium)) } .foregroundStyle(color.opacity(0.25)) .padding(.horizontal, 12) diff --git a/SportsTime/Features/Progress/ViewModels/ProgressViewModel.swift b/SportsTime/Features/Progress/ViewModels/ProgressViewModel.swift index e94baef..79a1695 100644 --- a/SportsTime/Features/Progress/ViewModels/ProgressViewModel.swift +++ b/SportsTime/Features/Progress/ViewModels/ProgressViewModel.swift @@ -49,10 +49,8 @@ final class ProgressViewModel { /// Pre-computed progress fractions per sport (avoids filtering visits per sport per render) private(set) var sportProgressFractions: [Sport: Double] = [:] - /// Stadiums for the selected sport - var sportStadiums: [Stadium] { - stadiums.filter { $0.sport == selectedSport } - } + /// Stadiums for the selected sport (cached, recomputed on data change) + private(set) var sportStadiums: [Stadium] = [] /// Count of trips for the selected sport (stub - can be enhanced) var tripCount: Int { @@ -60,30 +58,8 @@ final class ProgressViewModel { 0 } - /// Recent visits sorted by date - var recentVisits: [VisitSummary] { - visits - .sorted { $0.visitDate > $1.visitDate } - .prefix(10) - .compactMap { visit -> VisitSummary? in - guard let stadium = dataProvider.stadium(for: visit.stadiumId), - let sport = visit.sportEnum else { - return nil - } - return VisitSummary( - id: visit.id, - stadium: stadium, - visitDate: visit.visitDate, - visitType: visit.visitType, - sport: sport, - homeTeamName: visit.homeTeamName, - awayTeamName: visit.awayTeamName, - score: visit.finalScore, - photoCount: visit.photoMetadata?.count ?? 0, - notes: visit.notes - ) - } - } + /// Recent visits sorted by date (cached, recomputed on data change) + private(set) var recentVisits: [VisitSummary] = [] // MARK: - Configuration @@ -154,7 +130,8 @@ final class ProgressViewModel { private func recomputeDerivedState() { // Compute league progress once - let sportStadiums = stadiums.filter { $0.sport == selectedSport } + let filteredStadiums = stadiums.filter { $0.sport == selectedSport } + self.sportStadiums = filteredStadiums let visitedStadiumIds = Set( visits @@ -164,12 +141,12 @@ final class ProgressViewModel { } ) - let visited = sportStadiums.filter { visitedStadiumIds.contains($0.id) } - let remaining = sportStadiums.filter { !visitedStadiumIds.contains($0.id) } + let visited = filteredStadiums.filter { visitedStadiumIds.contains($0.id) } + let remaining = filteredStadiums.filter { !visitedStadiumIds.contains($0.id) } leagueProgress = LeagueProgress( sport: selectedSport, - totalStadiums: sportStadiums.count, + totalStadiums: filteredStadiums.count, visitedStadiums: visited.count, stadiumsVisited: visited, stadiumsRemaining: remaining @@ -216,6 +193,33 @@ final class ProgressViewModel { let visited = min(sportCounts[sport] ?? 0, total) return (sport, total > 0 ? Double(visited) / Double(total) : 0) }) + + // Recompute recent visits cache + recomputeRecentVisits() + } + + private func recomputeRecentVisits() { + recentVisits = visits + .sorted { $0.visitDate > $1.visitDate } + .prefix(10) + .compactMap { visit -> VisitSummary? in + guard let stadium = dataProvider.stadium(for: visit.stadiumId), + let sport = visit.sportEnum else { + return nil + } + return VisitSummary( + id: visit.id, + stadium: stadium, + visitDate: visit.visitDate, + visitType: visit.visitType, + sport: sport, + homeTeamName: visit.homeTeamName, + awayTeamName: visit.awayTeamName, + score: visit.finalScore, + photoCount: visit.photoMetadata?.count ?? 0, + notes: visit.notes + ) + } } // MARK: - Progress Card Generation diff --git a/SportsTime/Features/Progress/Views/ProgressTabView.swift b/SportsTime/Features/Progress/Views/ProgressTabView.swift index 6ee4448..261b0c3 100644 --- a/SportsTime/Features/Progress/Views/ProgressTabView.swift +++ b/SportsTime/Features/Progress/Views/ProgressTabView.swift @@ -247,6 +247,8 @@ struct ProgressTabView: View { label: "Complete" ) } + .accessibilityElement(children: .ignore) + .accessibilityLabel("Statistics: \(progress.visitedStadiums) visited, \(progress.totalStadiums - progress.visitedStadiums) remaining, \(String(format: "%.0f", progress.completionPercentage)) percent complete") } .padding(Theme.Spacing.lg) .background(Theme.cardBackground(colorScheme)) diff --git a/SportsTime/Features/Progress/Views/StadiumVisitSheet.swift b/SportsTime/Features/Progress/Views/StadiumVisitSheet.swift index d4f1f94..2187baa 100644 --- a/SportsTime/Features/Progress/Views/StadiumVisitSheet.swift +++ b/SportsTime/Features/Progress/Views/StadiumVisitSheet.swift @@ -114,6 +114,8 @@ struct StadiumVisitSheet: View { .foregroundStyle(Theme.textMuted(colorScheme)) } } + .accessibilityLabel(selectedStadium != nil ? "Stadium: \(selectedStadium!.name)" : "Select stadium") + .accessibilityHint("Opens stadium picker") } header: { Text("Location") } @@ -122,6 +124,7 @@ struct StadiumVisitSheet: View { // Visit Details Section Section { DatePicker("Date", selection: $visitDate, displayedComponents: .date) + .accessibilityHint("Select the date you visited this stadium") Picker("Visit Type", selection: $visitType) { ForEach(VisitType.allCases, id: \.self) { type in diff --git a/SportsTime/Features/Settings/Views/SettingsView.swift b/SportsTime/Features/Settings/Views/SettingsView.swift index 0f7130f..c5beace 100644 --- a/SportsTime/Features/Settings/Views/SettingsView.swift +++ b/SportsTime/Features/Settings/Views/SettingsView.swift @@ -229,6 +229,9 @@ struct SettingsView: View { .accessibilityHidden(true) } } + .accessibilityLabel("Animated background") + .accessibilityValue(DesignStyleManager.shared.animationsEnabled ? "On" : "Off") + .accessibilityHint("Toggle animated sports graphics on home screen") .accessibilityIdentifier("settings.animationsToggle") } header: { Text("Home Screen") diff --git a/SportsTime/Features/Trip/Views/TripDetailView.swift b/SportsTime/Features/Trip/Views/TripDetailView.swift index fb495da..36f39d6 100644 --- a/SportsTime/Features/Trip/Views/TripDetailView.swift +++ b/SportsTime/Features/Trip/Views/TripDetailView.swift @@ -6,7 +6,6 @@ import SwiftUI import SwiftData import MapKit -import Combine import UniformTypeIdentifiers struct TripDetailView: View { @@ -41,12 +40,14 @@ struct TripDetailView: View { @State private var itineraryItems: [ItineraryItem] = [] @State private var addItemAnchor: AddItemAnchor? @State private var editingItem: ItineraryItem? - @State private var subscriptionCancellable: AnyCancellable? + @State private var mapUpdateTask: Task? @State private var draggedItem: ItineraryItem? @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] = [] + @State private var cachedTripDays: [Date] = [] + @State private var cachedRouteWaypoints: [(name: String, coordinate: CLLocationCoordinate2D, isCustomItem: Bool)] = [] // Apple Maps state @State private var showMultiRouteAlert = false @@ -134,25 +135,32 @@ struct TripDetailView: View { await loadGamesIfNeeded() if allowCustomItems { await loadItineraryItems() - await setupSubscription() } + recomputeTripDays() recomputeSections() + recomputeRouteWaypoints() } .onDisappear { - subscriptionCancellable?.cancel() + mapUpdateTask?.cancel() demoSaveTask?.cancel() } .onChange(of: itineraryItems) { _, newItems in handleItineraryItemsChange(newItems) + recomputeTripDays() recomputeSections() + recomputeRouteWaypoints() } .onChange(of: travelOverrides.count) { _, _ in draggedTravelId = nil dropTargetId = nil + recomputeTripDays() recomputeSections() + recomputeRouteWaypoints() } .onChange(of: loadedGames.count) { _, _ in + recomputeTripDays() recomputeSections() + recomputeRouteWaypoints() } .overlay { if isExporting { exportProgressOverlay } @@ -194,7 +202,8 @@ struct TripDetailView: View { print("🗺️ [MapUpdate] Mappable: \(info.title) on day \(item.day), sortOrder: \(item.sortOrder)") } } - Task { + mapUpdateTask?.cancel() + mapUpdateTask = Task { updateMapRegion() await fetchDrivingRoutes() } @@ -865,9 +874,16 @@ struct TripDetailView: View { } private var tripDays: [Date] { + cachedTripDays + } + + private func recomputeTripDays() { let calendar = Calendar.current guard let startDate = trip.stops.first?.arrivalDate, - let endDate = trip.stops.last?.departureDate else { return [] } + let endDate = trip.stops.last?.departureDate else { + cachedTripDays = [] + return + } var days: [Date] = [] var current = calendar.startOfDay(for: startDate) @@ -877,7 +893,7 @@ struct TripDetailView: View { days.append(current) current = calendar.date(byAdding: .day, value: 1, to: current)! } - return days + cachedTripDays = days } private func gamesOn(date: Date) -> [RichGame] { @@ -1045,6 +1061,10 @@ struct TripDetailView: View { /// Route waypoints including both game stops and mappable custom items in itinerary order private var routeWaypoints: [(name: String, coordinate: CLLocationCoordinate2D, isCustomItem: Bool)] { + cachedRouteWaypoints + } + + private func recomputeRouteWaypoints() { // Build an ordered list combining game stops and mappable custom items // Items are ordered by (day, sortOrder) - visual order matches route order let itemsByDay = Dictionary(grouping: mappableCustomItems) { $0.day } @@ -1125,7 +1145,7 @@ struct TripDetailView: View { } } - return waypoints + cachedRouteWaypoints = waypoints } private func updateMapRegion() { @@ -1357,13 +1377,6 @@ struct TripDetailView: View { // MARK: - Itinerary Items (CloudKit persistence) - private func setupSubscription() async { - // TODO: Re-implement CloudKit subscription for ItineraryItem changes - // The subscription service was removed during the ItineraryItem refactor. - // For now, items are only loaded on view appear. - print("📡 [Subscription] CloudKit subscriptions not yet implemented for ItineraryItem") - } - private func loadItineraryItems() async { print("🔍 [ItineraryItems] Loading items for trip: \(trip.id)") do { @@ -1685,6 +1698,8 @@ struct DaySection: View { } .frame(maxWidth: .infinity, alignment: .leading) .cardStyle() + .accessibilityElement(children: .combine) + .accessibilityLabel("Day \(dayNumber), \(formattedDate), rest day") } else { // Full game day display VStack(alignment: .leading, spacing: Theme.Spacing.sm) { @@ -1719,6 +1734,8 @@ struct DaySection: View { } } .cardStyle() + .accessibilityElement(children: .combine) + .accessibilityLabel("Day \(dayNumber), \(formattedDate), \(games.count) game\(games.count > 1 ? "s" : "") in \(gameCity ?? "unknown city")") } } } @@ -2032,6 +2049,8 @@ struct TripMapView: View { } .id(routeVersion) // Force Map to recreate when routes change .mapStyle(colorScheme == .dark ? .standard(elevation: .flat, emphasis: .muted) : .standard) + .accessibilityElement(children: .contain) + .accessibilityLabel("Trip route map showing \(stopCoordinates.count) stops") } }