From 91c5eac22d5dcb2d61a5c53afdfaae8f65240254 Mon Sep 17 00:00:00 2001 From: Trey t Date: Sun, 22 Feb 2026 00:07:53 -0600 Subject: [PATCH] =?UTF-8?q?fix:=20codebase=20audit=20fixes=20=E2=80=94=20s?= =?UTF-8?q?afety,=20accessibility,=20and=20production=20hygiene?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address 16 issues from external audit: - Move StoreKit transaction listener ownership to StoreManager singleton with proper deinit - Remove noisy VoiceOver announcements, add missing accessibility on StatPill and BootstrapLoadingView - Replace String @retroactive Identifiable with IdentifiableShareCode wrapper - Add crash guard in AchievementEngine getContributingVisitIds + cache stadium lookups - Pre-compute GamesHistoryViewModel filtered properties to avoid redundant SwiftUI recomputation - Remove force-unwraps in ProgressMapView with safe guard-let fallback - Add diff-based update gating in ItineraryTableViewWrapper to prevent unnecessary reloads - Replace deprecated UIScreen.main with UIWindowScene lookup - Add deinit task cancellation in ScheduleViewModel and SuggestedTripsGenerator - Wrap ~234 unguarded print() calls across 27 files in #if DEBUG Co-Authored-By: Claude Opus 4.6 --- .../Core/Models/CloudKit/CKModels.swift | 14 +++++ .../Core/Services/AchievementEngine.swift | 18 +++--- SportsTime/Core/Services/AppDelegate.swift | 4 ++ .../Core/Services/BackgroundSyncManager.swift | 14 +++++ SportsTime/Core/Services/DataProvider.swift | 4 ++ .../Core/Services/DeepLinkHandler.swift | 11 +++- SportsTime/Core/Services/GameMatcher.swift | 18 ++++++ .../Core/Services/HistoricalGameScraper.swift | 30 +++++++++ .../Services/PhotoMetadataExtractor.swift | 24 ++++++++ .../Services/SuggestedTripsGenerator.swift | 10 ++- SportsTime/Core/Services/SyncLogger.swift | 2 + SportsTime/Core/Store/StoreManager.swift | 23 ++++++- .../Core/Theme/AnimatedComponents.swift | 1 + SportsTime/Core/Theme/SportSelectorGrid.swift | 2 + .../ViewModels/GamesHistoryViewModel.swift | 61 +++++++++---------- .../ViewModels/PhotoImportViewModel.swift | 22 +++++++ .../Progress/Views/ProgressMapView.swift | 13 ++-- .../ViewModels/ScheduleViewModel.swift | 6 +- .../Trip/Views/AddItem/PlaceSearchSheet.swift | 2 + .../Views/AddItem/QuickAddItemSheet.swift | 6 ++ .../Trip/Views/ItineraryReorderingLogic.swift | 10 +++ .../Views/ItineraryTableViewController.swift | 2 + .../Views/ItineraryTableViewWrapper.swift | 27 +++++++- .../Features/Trip/Views/TripDetailView.swift | 42 +++++++++++++ .../Trip/Views/Wizard/TripWizardView.swift | 2 + .../Planning/Engine/GameDAGRouter.swift | 4 ++ .../Planning/Engine/ScenarioAPlanner.swift | 16 +++++ .../Planning/Engine/ScenarioBPlanner.swift | 4 ++ .../Planning/Engine/ScenarioDPlanner.swift | 30 +++++++++ .../Planning/Engine/ScenarioEPlanner.swift | 12 ++++ .../Planning/Engine/ScenarioPlanner.swift | 14 +++++ SportsTime/SportsTimeApp.swift | 53 +++++++++++----- 32 files changed, 434 insertions(+), 67 deletions(-) diff --git a/SportsTime/Core/Models/CloudKit/CKModels.swift b/SportsTime/Core/Models/CloudKit/CKModels.swift index 00f431e..85aeaa1 100644 --- a/SportsTime/Core/Models/CloudKit/CKModels.swift +++ b/SportsTime/Core/Models/CloudKit/CKModels.swift @@ -636,27 +636,37 @@ nonisolated struct CKTripPoll { guard let pollIdString = record[CKTripPoll.pollIdKey] as? String, let pollId = UUID(uuidString: pollIdString) else { + #if DEBUG print("CKTripPoll.toPoll: Failed to parse pollId") + #endif return nil } guard let title = record[CKTripPoll.titleKey] as? String else { + #if DEBUG print("CKTripPoll.toPoll: Missing title for poll \(pollId)") + #endif return nil } guard let ownerId = record[CKTripPoll.ownerIdKey] as? String else { + #if DEBUG print("CKTripPoll.toPoll: Missing ownerId for poll \(pollId)") + #endif return nil } guard let shareCode = record[CKTripPoll.shareCodeKey] as? String else { + #if DEBUG print("CKTripPoll.toPoll: Missing shareCode for poll \(pollId)") + #endif return nil } guard let createdAt = record[CKTripPoll.createdAtKey] as? Date else { + #if DEBUG print("CKTripPoll.toPoll: Missing createdAt for poll \(pollId)") + #endif return nil } @@ -669,11 +679,15 @@ nonisolated struct CKTripPoll { do { tripSnapshots = try JSONDecoder().decode([Trip].self, from: tripsData) } catch { + #if DEBUG print("CKTripPoll.toPoll: Failed to decode tripSnapshots for poll \(pollId): \(error)") + #endif // Return poll with empty trips rather than failing completely } } else { + #if DEBUG print("CKTripPoll.toPoll: Missing tripSnapshots data for poll \(pollId)") + #endif } var poll = TripPoll( diff --git a/SportsTime/Core/Services/AchievementEngine.swift b/SportsTime/Core/Services/AchievementEngine.swift index 2a773d7..2482f6b 100644 --- a/SportsTime/Core/Services/AchievementEngine.swift +++ b/SportsTime/Core/Services/AchievementEngine.swift @@ -30,6 +30,7 @@ final class AchievementEngine { private let modelContext: ModelContext private let dataProvider: AppDataProvider + private var stadiumIdsBySport: [Sport: [String]]? // MARK: - Initialization @@ -356,6 +357,7 @@ final class AchievementEngine { case .visitsInDays(let requiredVisits, let days): // Find the qualifying window of visits let sortedVisits = visits.sorted { $0.visitDate < $1.visitDate } + guard sortedVisits.count >= requiredVisits else { return [] } for i in 0...(sortedVisits.count - requiredVisits) { let windowStart = sortedVisits[i].visitDate let windowEnd = sortedVisits[i + requiredVisits - 1].visitDate @@ -399,15 +401,15 @@ final class AchievementEngine { } private func getStadiumIdsForLeague(_ sport: Sport) -> [String] { - // Get all stadium canonical IDs for this sport - return dataProvider.stadiums - .filter { stadium in - // Check if stadium hosts teams of this sport - dataProvider.teams.contains { team in - team.stadiumId == stadium.id && team.sport == sport - } + // Build cache lazily on first access + if stadiumIdsBySport == nil { + var cache: [Sport: [String]] = [:] + for team in dataProvider.teams { + cache[team.sport, default: []].append(team.stadiumId) } - .map { $0.id } + stadiumIdsBySport = cache + } + return stadiumIdsBySport?[sport] ?? [] } // MARK: - Data Fetching diff --git a/SportsTime/Core/Services/AppDelegate.swift b/SportsTime/Core/Services/AppDelegate.swift index 68931f2..04e8960 100644 --- a/SportsTime/Core/Services/AppDelegate.swift +++ b/SportsTime/Core/Services/AppDelegate.swift @@ -24,14 +24,18 @@ class AppDelegate: NSObject, UIApplicationDelegate { _ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data ) { + #if DEBUG print("📡 [Push] Registered for remote notifications") + #endif } func application( _ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error ) { + #if DEBUG print("📡 [Push] Failed to register: \(error.localizedDescription)") + #endif } func application( diff --git a/SportsTime/Core/Services/BackgroundSyncManager.swift b/SportsTime/Core/Services/BackgroundSyncManager.swift index 4709bbe..d92df94 100644 --- a/SportsTime/Core/Services/BackgroundSyncManager.swift +++ b/SportsTime/Core/Services/BackgroundSyncManager.swift @@ -84,7 +84,9 @@ final class BackgroundSyncManager { } } + #if DEBUG print("Background tasks registered") + #endif } // MARK: - Task Scheduling @@ -100,9 +102,13 @@ final class BackgroundSyncManager { do { try BGTaskScheduler.shared.submit(request) + #if DEBUG print("Background refresh scheduled") + #endif } catch { + #if DEBUG print("Failed to schedule background refresh: \(error.localizedDescription)") + #endif } } @@ -129,9 +135,13 @@ final class BackgroundSyncManager { do { try BGTaskScheduler.shared.submit(request) + #if DEBUG print("Overnight processing task scheduled for \(targetDate)") + #endif } catch { + #if DEBUG print("Failed to schedule processing task: \(error.localizedDescription)") + #endif } } @@ -519,11 +529,15 @@ final class BackgroundSyncManager { } try context.save() + #if DEBUG print("Background cleanup: Archived \(oldGames.count) old games") + #endif return true } catch { + #if DEBUG print("Background cleanup failed: \(error.localizedDescription)") + #endif return false } } diff --git a/SportsTime/Core/Services/DataProvider.swift b/SportsTime/Core/Services/DataProvider.swift index c45d0fe..d432186 100644 --- a/SportsTime/Core/Services/DataProvider.swift +++ b/SportsTime/Core/Services/DataProvider.swift @@ -217,7 +217,9 @@ final class AppDataProvider: ObservableObject { func filterRichGames(sports: Set, startDate: Date, endDate: Date) async throws -> [RichGame] { let games = try await filterGames(sports: sports, startDate: startDate, endDate: endDate) + #if DEBUG print("🎮 [DATA] filterRichGames: \(games.count) games from SwiftData for \(sports.map(\.rawValue).joined(separator: ", "))") + #endif var richGames: [RichGame] = [] var droppedGames: [(game: Game, reason: String)] = [] @@ -243,6 +245,7 @@ final class AppDataProvider: ObservableObject { richGames.append(RichGame(game: game, homeTeam: homeTeam!, awayTeam: awayTeam!, stadium: resolvedStadium!)) } + #if DEBUG if !droppedGames.isEmpty { print("âš ī¸ [DATA] Dropped \(droppedGames.count) games due to missing lookups:") for (game, reason) in droppedGames.prefix(10) { @@ -257,6 +260,7 @@ final class AppDataProvider: ObservableObject { } print("🎮 [DATA] Returning \(richGames.count) rich games") + #endif return richGames } diff --git a/SportsTime/Core/Services/DeepLinkHandler.swift b/SportsTime/Core/Services/DeepLinkHandler.swift index 8eb36ed..b716d5e 100644 --- a/SportsTime/Core/Services/DeepLinkHandler.swift +++ b/SportsTime/Core/Services/DeepLinkHandler.swift @@ -8,6 +8,13 @@ import Foundation import SwiftUI +// MARK: - Identifiable Share Code + +struct IdentifiableShareCode: Identifiable { + let id: String + var value: String { id } +} + // MARK: - Deep Link Handler @MainActor @@ -19,7 +26,7 @@ final class DeepLinkHandler { /// The pending poll share code from a deep link /// Setting to nil clears the pending state - var pendingPollShareCode: String? { + var pendingPollShareCode: IdentifiableShareCode? { didSet { if pendingPollShareCode == nil { pendingPoll = nil @@ -49,7 +56,7 @@ final class DeepLinkHandler { switch destination { case .poll(let shareCode): - pendingPollShareCode = shareCode + pendingPollShareCode = IdentifiableShareCode(id: shareCode) } return destination diff --git a/SportsTime/Core/Services/GameMatcher.swift b/SportsTime/Core/Services/GameMatcher.swift index b3d982c..a9af2ed 100644 --- a/SportsTime/Core/Services/GameMatcher.swift +++ b/SportsTime/Core/Services/GameMatcher.swift @@ -293,6 +293,7 @@ final class GameMatcher { metadata: PhotoMetadata, sport: Sport? = nil ) async -> PhotoImportCandidate { + #if DEBUG print("đŸŽ¯ [GameMatcher] Processing photo for import") print("đŸŽ¯ [GameMatcher] - hasValidDate: \(metadata.hasValidDate)") print("đŸŽ¯ [GameMatcher] - captureDate: \(metadata.captureDate?.description ?? "nil")") @@ -300,6 +301,7 @@ final class GameMatcher { if let coords = metadata.coordinates { print("đŸŽ¯ [GameMatcher] - coordinates: \(coords.latitude), \(coords.longitude)") } + #endif // Get stadium matches regardless of game matching var stadiumMatches: [StadiumMatch] = [] @@ -308,16 +310,21 @@ final class GameMatcher { coordinates: coordinates, sport: sport ) + #if DEBUG print("đŸŽ¯ [GameMatcher] - nearby stadiums found: \(stadiumMatches.count)") for match in stadiumMatches.prefix(3) { print("đŸŽ¯ [GameMatcher] â€ĸ \(match.stadium.name) (\(String(format: "%.1f", match.distance / 1609.34)) mi)") } + #endif } else { + #if DEBUG print("đŸŽ¯ [GameMatcher] - no coordinates, skipping stadium proximity search") + #endif } var matchResult = await matchGame(metadata: metadata, sport: sport) + #if DEBUG switch matchResult { case .singleMatch(let match): print("đŸŽ¯ [GameMatcher] - result: singleMatch - \(match.homeTeam.fullName) vs \(match.awayTeam.fullName)") @@ -325,21 +332,30 @@ final class GameMatcher { print("đŸŽ¯ [GameMatcher] - result: multipleMatches (\(matches.count) games)") case .noMatches(let reason): print("đŸŽ¯ [GameMatcher] - result: noMatches - \(reason)") + } + #endif + if case .noMatches = matchResult { // FALLBACK: Try scraping historical game data if we have stadium + date // Try all nearby stadiums (important for multi-sport venues like American Airlines Center) if let captureDate = metadata.captureDate, !stadiumMatches.isEmpty { + #if DEBUG print("đŸŽ¯ [GameMatcher] - Trying historical scraper fallback...") + #endif for stadiumMatch in stadiumMatches { let stadium = stadiumMatch.stadium + #if DEBUG print("đŸŽ¯ [GameMatcher] - Trying \(stadium.name) (\(stadium.sport.rawValue))...") + #endif if let scrapedGame = await HistoricalGameScraper.shared.scrapeGame( stadium: stadium, date: captureDate ) { + #if DEBUG print("đŸŽ¯ [GameMatcher] - ✅ Scraper found: \(scrapedGame.awayTeam) @ \(scrapedGame.homeTeam)") + #endif // Convert ScrapedGame to a match result matchResult = .singleMatch(GameMatchCandidate( @@ -350,9 +366,11 @@ final class GameMatcher { } } + #if DEBUG if case .noMatches = matchResult { print("đŸŽ¯ [GameMatcher] - Scraper found no game at any stadium") } + #endif } } diff --git a/SportsTime/Core/Services/HistoricalGameScraper.swift b/SportsTime/Core/Services/HistoricalGameScraper.swift index a8c98d8..9ad42a7 100644 --- a/SportsTime/Core/Services/HistoricalGameScraper.swift +++ b/SportsTime/Core/Services/HistoricalGameScraper.swift @@ -44,13 +44,17 @@ actor HistoricalGameScraper { // Check cache first if let cached = cache[cacheKey] { + #if DEBUG print("🌐 [Scraper] Cache hit for \(stadium.name) on \(dateKey(date))") + #endif return cached } + #if DEBUG print("🌐 [Scraper] ════════════════════════════════════════") print("🌐 [Scraper] Scraping game for \(stadium.name) on \(dateKey(date))") print("🌐 [Scraper] Sport: \(stadium.sport.rawValue)") + #endif let result: ScrapedGame? switch stadium.sport { @@ -63,12 +67,15 @@ actor HistoricalGameScraper { case .nfl: result = await scrapeFootballReference(stadium: stadium, date: date) case .mls, .wnba, .nwsl: + #if DEBUG print("🌐 [Scraper] âš ī¸ \(stadium.sport.rawValue) scraping not yet implemented") + #endif result = nil } cache[cacheKey] = result + #if DEBUG if let game = result { print("🌐 [Scraper] ✅ Found: \(game.awayTeam) @ \(game.homeTeam)") if let score = game.formattedScore { @@ -78,6 +85,7 @@ actor HistoricalGameScraper { print("🌐 [Scraper] ❌ No game found") } print("🌐 [Scraper] ════════════════════════════════════════") + #endif return result } @@ -103,7 +111,9 @@ actor HistoricalGameScraper { } private func scrapeFootballReference(stadium: Stadium, date: Date) async -> ScrapedGame? { + #if DEBUG print("🌐 [Scraper] âš ī¸ NFL scraping not yet implemented") + #endif return nil } @@ -115,7 +125,9 @@ actor HistoricalGameScraper { date: Date, sport: Sport ) async -> ScrapedGame? { + #if DEBUG print("🌐 [Scraper] Fetching: \(urlString)") + #endif guard let url = URL(string: urlString) else { return nil } @@ -128,23 +140,33 @@ actor HistoricalGameScraper { guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200, let html = String(data: data, encoding: .utf8) else { + #if DEBUG print("🌐 [Scraper] ❌ Failed to fetch page") + #endif return nil } + #if DEBUG print("🌐 [Scraper] Parsing HTML (\(data.count) bytes)") + #endif let targetTeams = await findTeamNamesForStadium(stadium) guard !targetTeams.isEmpty else { + #if DEBUG print("🌐 [Scraper] ❌ No team mapping for stadium: \(stadium.name)") + #endif return nil } + #if DEBUG print("🌐 [Scraper] Looking for home team: \(targetTeams.first ?? "unknown")") + #endif // Parse with SwiftSoup let doc = try SwiftSoup.parse(html) let gameSummaries = try doc.select("div.game_summary") + #if DEBUG print("🌐 [Scraper] Found \(gameSummaries.count) games on this date") + #endif for game in gameSummaries { let rows = try game.select("table.teams tbody tr") @@ -159,7 +181,9 @@ actor HistoricalGameScraper { let awayScoreText = try awayRow.select("td.right").first()?.text() ?? "" let homeScoreText = try homeRow.select("td.right").first()?.text() ?? "" + #if DEBUG print("🌐 [Scraper] Game: \(awayTeam) @ \(homeTeam)") + #endif // Check if any of our target team names match the home team let isMatch = targetTeams.contains { targetName in @@ -181,10 +205,14 @@ actor HistoricalGameScraper { } } + #if DEBUG print("🌐 [Scraper] âš ī¸ No game found for team: \(targetTeams.first ?? "unknown")") + #endif } catch { + #if DEBUG print("🌐 [Scraper] ❌ Error: \(error.localizedDescription)") + #endif } return nil @@ -216,7 +244,9 @@ actor HistoricalGameScraper { // Find the team that plays at this stadium using canonical ID guard let team = teams.first(where: { $0.stadiumId == stadium.id }) else { + #if DEBUG print("🌐 [Scraper] No team found for stadium ID: \(stadium.id)") + #endif return [] } diff --git a/SportsTime/Core/Services/PhotoMetadataExtractor.swift b/SportsTime/Core/Services/PhotoMetadataExtractor.swift index 1119c97..1f58548 100644 --- a/SportsTime/Core/Services/PhotoMetadataExtractor.swift +++ b/SportsTime/Core/Services/PhotoMetadataExtractor.swift @@ -43,31 +43,43 @@ actor PhotoMetadataExtractor { /// Extract metadata from PHAsset (preferred method) /// Uses PHAsset's location and creationDate properties func extractMetadata(from asset: PHAsset) async -> PhotoMetadata { + #if DEBUG print("📸 [PhotoMetadata] ════════════════════════════════════════") print("📸 [PhotoMetadata] Extracting from PHAsset: \(asset.localIdentifier)") print("📸 [PhotoMetadata] Asset mediaType: \(asset.mediaType.rawValue) (1=image, 2=video)") print("📸 [PhotoMetadata] Asset creationDate: \(asset.creationDate?.description ?? "nil")") print("📸 [PhotoMetadata] Asset modificationDate: \(asset.modificationDate?.description ?? "nil")") print("📸 [PhotoMetadata] Asset location: \(asset.location?.description ?? "nil")") + #endif // PHAsset provides location and date directly let coordinates: CLLocationCoordinate2D? if let location = asset.location { coordinates = location.coordinate + #if DEBUG print("📸 [PhotoMetadata] ✅ Location found: \(coordinates!.latitude), \(coordinates!.longitude)") + #endif } else { coordinates = nil + #if DEBUG print("📸 [PhotoMetadata] âš ī¸ No location in PHAsset, trying ImageIO fallback...") + #endif // Try ImageIO extraction as fallback if let imageData = await loadImageData(from: asset) { + #if DEBUG print("📸 [PhotoMetadata] Loaded image data: \(imageData.count) bytes") + #endif let fallbackMetadata = extractMetadata(from: imageData) if fallbackMetadata.hasValidLocation || fallbackMetadata.hasValidDate { + #if DEBUG print("📸 [PhotoMetadata] ✅ ImageIO fallback found data - date: \(fallbackMetadata.captureDate?.description ?? "nil"), coords: \(fallbackMetadata.coordinates?.latitude ?? 0), \(fallbackMetadata.coordinates?.longitude ?? 0)") + #endif return fallbackMetadata } else { + #if DEBUG print("📸 [PhotoMetadata] âš ī¸ ImageIO fallback found no metadata either") + #endif } } } @@ -76,8 +88,10 @@ actor PhotoMetadataExtractor { captureDate: asset.creationDate, coordinates: coordinates ) + #if DEBUG print("📸 [PhotoMetadata] Final result - hasDate: \(result.hasValidDate), hasLocation: \(result.hasValidLocation)") print("📸 [PhotoMetadata] ════════════════════════════════════════") + #endif return result } @@ -96,18 +110,25 @@ actor PhotoMetadataExtractor { /// Extract metadata from raw image data using ImageIO /// Useful when PHAsset is not available func extractMetadata(from imageData: Data) -> PhotoMetadata { + #if DEBUG print("📸 [ImageIO] Extracting from image data (\(imageData.count) bytes)") + #endif guard let source = CGImageSourceCreateWithData(imageData as CFData, nil) else { + #if DEBUG print("📸 [ImageIO] ❌ Failed to create CGImageSource") + #endif return .empty } guard let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any] else { + #if DEBUG print("📸 [ImageIO] ❌ Failed to copy properties from source") + #endif return .empty } + #if DEBUG // Debug: Print all available property dictionaries print("📸 [ImageIO] Available property keys: \(properties.keys.map { $0 as String })") @@ -141,12 +162,15 @@ actor PhotoMetadataExtractor { } else { print("📸 [ImageIO] âš ī¸ No TIFF dictionary found") } + #endif let captureDate = extractDate(from: properties) let coordinates = extractCoordinates(from: properties) + #if DEBUG print("📸 [ImageIO] Extracted date: \(captureDate?.description ?? "nil")") print("📸 [ImageIO] Extracted coordinates: \(coordinates?.latitude ?? 0), \(coordinates?.longitude ?? 0)") + #endif return PhotoMetadata( captureDate: captureDate, diff --git a/SportsTime/Core/Services/SuggestedTripsGenerator.swift b/SportsTime/Core/Services/SuggestedTripsGenerator.swift index 07efb09..a958bf7 100644 --- a/SportsTime/Core/Services/SuggestedTripsGenerator.swift +++ b/SportsTime/Core/Services/SuggestedTripsGenerator.swift @@ -48,6 +48,7 @@ final class SuggestedTripsGenerator { // MARK: - Dependencies private let dataProvider = AppDataProvider.shared + private var generationTask: Task<[SuggestedTrip], Never>? // MARK: - Grouped Trips @@ -73,6 +74,8 @@ final class SuggestedTripsGenerator { func generateTrips() async { guard !isLoading else { return } + generationTask?.cancel() + isLoading = true error = nil suggestedTrips = [] @@ -121,7 +124,7 @@ final class SuggestedTripsGenerator { let teams = dataProvider.teams // Move heavy computation to background task - let result = await Task.detached(priority: .userInitiated) { + let task = Task.detached(priority: .userInitiated) { await Self.generateTripsInBackground( games: games, stadiums: stadiums, @@ -129,9 +132,10 @@ final class SuggestedTripsGenerator { startDate: startDate, endDate: endDate ) - }.value + } + generationTask = task - suggestedTrips = result + suggestedTrips = await task.value } catch { self.error = "Failed to generate trips: \(error.localizedDescription)" } diff --git a/SportsTime/Core/Services/SyncLogger.swift b/SportsTime/Core/Services/SyncLogger.swift index 9cabe8f..cdd2dcb 100644 --- a/SportsTime/Core/Services/SyncLogger.swift +++ b/SportsTime/Core/Services/SyncLogger.swift @@ -27,7 +27,9 @@ final class SyncLogger: @unchecked Sendable { let line = "[\(timestamp)] \(message)\n" // Also print to console + #if DEBUG print(message) + #endif queue.async { [weak self] in self?.appendToFile(line) diff --git a/SportsTime/Core/Store/StoreManager.swift b/SportsTime/Core/Store/StoreManager.swift index 4da84f1..38433d5 100644 --- a/SportsTime/Core/Store/StoreManager.swift +++ b/SportsTime/Core/Store/StoreManager.swift @@ -73,22 +73,33 @@ final class StoreManager { products.first { $0.id == "com.88oakapps.SportsTime.pro.annual2" } } + // MARK: - Transaction Listener + + nonisolated(unsafe) private var transactionListenerTask: Task? + // MARK: - Initialization private init() {} + deinit { + transactionListenerTask?.cancel() + } + // MARK: - Product Loading func loadProducts() async { isLoading = true error = nil + #if DEBUG print("[StoreManager] Loading products for IDs: \(Self.proProductIDs)") + #endif do { let fetchedProducts = try await Product.products(for: Self.proProductIDs) products = fetchedProducts + #if DEBUG print("[StoreManager] Loaded \(fetchedProducts.count) products:") for product in fetchedProducts { print("[StoreManager] - \(product.id): \(product.displayPrice) (\(product.displayName))") @@ -101,16 +112,21 @@ final class StoreManager { } } } + #endif // Treat an empty fetch as a configuration issue so UI can show a useful fallback. if fetchedProducts.isEmpty { + #if DEBUG print("[StoreManager] WARNING: No products returned — check Configuration.storekit") + #endif error = .productNotFound } } catch { products = [] self.error = .productNotFound + #if DEBUG print("[StoreManager] ERROR loading products: \(error)") + #endif } isLoading = false @@ -289,10 +305,11 @@ final class StoreManager { return "unknown" } - // MARK: - Transaction Listener + // MARK: - Transaction Listening - func listenForTransactions() -> Task { - Task.detached { + func startListeningForTransactions() { + transactionListenerTask?.cancel() + transactionListenerTask = Task.detached { for await result in Transaction.updates { if case .verified(let transaction) = result { await transaction.finish() diff --git a/SportsTime/Core/Theme/AnimatedComponents.swift b/SportsTime/Core/Theme/AnimatedComponents.swift index 8d48489..4e470ee 100644 --- a/SportsTime/Core/Theme/AnimatedComponents.swift +++ b/SportsTime/Core/Theme/AnimatedComponents.swift @@ -198,6 +198,7 @@ struct StatPill: View { HStack(spacing: 6) { Image(systemName: icon) .font(.caption) + .accessibilityHidden(true) Text(value) .font(.footnote) } diff --git a/SportsTime/Core/Theme/SportSelectorGrid.swift b/SportsTime/Core/Theme/SportSelectorGrid.swift index 688c72a..84dcb30 100644 --- a/SportsTime/Core/Theme/SportSelectorGrid.swift +++ b/SportsTime/Core/Theme/SportSelectorGrid.swift @@ -269,7 +269,9 @@ extension SportSelectorGrid where Content == SportToggleButton { SportSelectorGrid { sport in SportActionButton(sport: sport) { + #if DEBUG print("Selected \(sport.rawValue)") + #endif } } } diff --git a/SportsTime/Features/Progress/ViewModels/GamesHistoryViewModel.swift b/SportsTime/Features/Progress/ViewModels/GamesHistoryViewModel.swift index a5c4f8d..2de64e4 100644 --- a/SportsTime/Features/Progress/ViewModels/GamesHistoryViewModel.swift +++ b/SportsTime/Features/Progress/ViewModels/GamesHistoryViewModel.swift @@ -7,40 +7,17 @@ final class GamesHistoryViewModel { private let modelContext: ModelContext var allVisits: [StadiumVisit] = [] - var selectedSports: Set = [] + var selectedSports: Set = [] { + didSet { recomputeFilteredData() } + } var isLoading = false var error: String? - // Computed: visits grouped by year - var visitsByYear: [Int: [StadiumVisit]] { - let calendar = Calendar.current - let filtered = filteredVisits - return Dictionary(grouping: filtered) { visit in - calendar.component(.year, from: visit.visitDate) - } - } - - // Computed: sorted year keys (descending) - var sortedYears: [Int] { - visitsByYear.keys.sorted(by: >) - } - - // Computed: filtered by selected sports - var filteredVisits: [StadiumVisit] { - guard !selectedSports.isEmpty else { return allVisits } - - return allVisits.filter { visit in - guard let stadium = AppDataProvider.shared.stadium(for: visit.stadiumId) else { - return false - } - return selectedSports.contains(stadium.sport) - } - } - - // Total count - var totalGamesCount: Int { - filteredVisits.count - } + // Pre-computed stored properties (updated via recomputeFilteredData) + private(set) var filteredVisits: [StadiumVisit] = [] + private(set) var visitsByYear: [Int: [StadiumVisit]] = [:] + private(set) var sortedYears: [Int] = [] + private(set) var totalGamesCount: Int = 0 init(modelContext: ModelContext) { self.modelContext = modelContext @@ -60,6 +37,8 @@ final class GamesHistoryViewModel { self.error = "Failed to load games: \(error.localizedDescription)" allVisits = [] } + + recomputeFilteredData() } func toggleSport(_ sport: Sport) { @@ -73,4 +52,24 @@ final class GamesHistoryViewModel { func clearFilters() { selectedSports.removeAll() } + + private func recomputeFilteredData() { + if selectedSports.isEmpty { + filteredVisits = allVisits + } else { + filteredVisits = allVisits.filter { visit in + guard let stadium = AppDataProvider.shared.stadium(for: visit.stadiumId) else { + return false + } + return selectedSports.contains(stadium.sport) + } + } + + let calendar = Calendar.current + visitsByYear = Dictionary(grouping: filteredVisits) { visit in + calendar.component(.year, from: visit.visitDate) + } + sortedYears = visitsByYear.keys.sorted(by: >) + totalGamesCount = filteredVisits.count + } } diff --git a/SportsTime/Features/Progress/ViewModels/PhotoImportViewModel.swift b/SportsTime/Features/Progress/ViewModels/PhotoImportViewModel.swift index d6ace6a..e65b2fa 100644 --- a/SportsTime/Features/Progress/ViewModels/PhotoImportViewModel.swift +++ b/SportsTime/Features/Progress/ViewModels/PhotoImportViewModel.swift @@ -47,8 +47,10 @@ final class PhotoImportViewModel { func processSelectedPhotos(_ items: [PhotosPickerItem]) async { guard !items.isEmpty else { return } + #if DEBUG print("📷 [PhotoImport] ════════════════════════════════════════════════") print("📷 [PhotoImport] Starting photo import with \(items.count) items") + #endif isProcessing = true totalCount = items.count @@ -61,16 +63,21 @@ final class PhotoImportViewModel { var assets: [PHAsset] = [] for (index, item) in items.enumerated() { + #if DEBUG print("📷 [PhotoImport] ────────────────────────────────────────────────") print("📷 [PhotoImport] Processing item \(index + 1)/\(items.count)") print("📷 [PhotoImport] Item identifier: \(item.itemIdentifier ?? "nil")") print("📷 [PhotoImport] Item supportedContentTypes: \(item.supportedContentTypes)") + #endif if let assetId = item.itemIdentifier { let fetchResult = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil) + #if DEBUG print("📷 [PhotoImport] PHAsset fetch result count: \(fetchResult.count)") + #endif if let asset = fetchResult.firstObject { + #if DEBUG print("📷 [PhotoImport] ✅ Found PHAsset") print("📷 [PhotoImport] - localIdentifier: \(asset.localIdentifier)") print("📷 [PhotoImport] - mediaType: \(asset.mediaType.rawValue)") @@ -79,22 +86,30 @@ final class PhotoImportViewModel { print("📷 [PhotoImport] - sourceType: \(asset.sourceType.rawValue)") print("📷 [PhotoImport] - pixelWidth: \(asset.pixelWidth)") print("📷 [PhotoImport] - pixelHeight: \(asset.pixelHeight)") + #endif assets.append(asset) } else { + #if DEBUG print("📷 [PhotoImport] âš ī¸ No PHAsset found for identifier") + #endif } } else { + #if DEBUG print("📷 [PhotoImport] âš ī¸ No itemIdentifier on PhotosPickerItem") + #endif } processedCount += 1 } + #if DEBUG print("📷 [PhotoImport] ────────────────────────────────────────────────") print("📷 [PhotoImport] Loaded \(assets.count) PHAssets, extracting metadata...") + #endif // Extract metadata from all assets let metadataList = await metadataExtractor.extractMetadata(from: assets) + #if DEBUG print("📷 [PhotoImport] ────────────────────────────────────────────────") print("📷 [PhotoImport] Extracted \(metadataList.count) metadata records") @@ -103,25 +118,32 @@ final class PhotoImportViewModel { let withDate = metadataList.filter { $0.hasValidDate }.count print("📷 [PhotoImport] Photos with location: \(withLocation)/\(metadataList.count)") print("📷 [PhotoImport] Photos with date: \(withDate)/\(metadataList.count)") + #endif // Process each photo through game matcher processedCount = 0 for (index, metadata) in metadataList.enumerated() { + #if DEBUG print("📷 [PhotoImport] Matching photo \(index + 1): date=\(metadata.captureDate?.description ?? "nil"), location=\(metadata.hasValidLocation)") + #endif let candidate = await gameMatcher.processPhotoForImport(metadata: metadata) processedPhotos.append(candidate) // Auto-confirm high-confidence matches if candidate.canAutoProcess { confirmedImports.insert(candidate.id) + #if DEBUG print("📷 [PhotoImport] ✅ Auto-confirmed match") + #endif } processedCount += 1 } + #if DEBUG print("📷 [PhotoImport] ════════════════════════════════════════════════") print("📷 [PhotoImport] Import complete: \(processedPhotos.count) photos, \(confirmedImports.count) auto-confirmed") + #endif isProcessing = false } diff --git a/SportsTime/Features/Progress/Views/ProgressMapView.swift b/SportsTime/Features/Progress/Views/ProgressMapView.swift index 68b918e..f2da95c 100644 --- a/SportsTime/Features/Progress/Views/ProgressMapView.swift +++ b/SportsTime/Features/Progress/Views/ProgressMapView.swift @@ -182,10 +182,15 @@ extension ProgressMapView { let latitudes = stadiums.map { $0.latitude } let longitudes = stadiums.map { $0.longitude } - let minLat = latitudes.min()! - let maxLat = latitudes.max()! - let minLon = longitudes.min()! - let maxLon = longitudes.max()! + guard let minLat = latitudes.min(), + let maxLat = latitudes.max(), + let minLon = longitudes.min(), + let maxLon = longitudes.max() else { + return MKCoordinateRegion( + center: CLLocationCoordinate2D(latitude: 39.8283, longitude: -98.5795), + span: MKCoordinateSpan(latitudeDelta: 50, longitudeDelta: 50) + ) + } let center = CLLocationCoordinate2D( latitude: (minLat + maxLat) / 2, diff --git a/SportsTime/Features/Schedule/ViewModels/ScheduleViewModel.swift b/SportsTime/Features/Schedule/ViewModels/ScheduleViewModel.swift index 5889a98..a35e521 100644 --- a/SportsTime/Features/Schedule/ViewModels/ScheduleViewModel.swift +++ b/SportsTime/Features/Schedule/ViewModels/ScheduleViewModel.swift @@ -28,7 +28,7 @@ final class ScheduleViewModel { private(set) var errorMessage: String? private let dataProvider = AppDataProvider.shared - private var loadTask: Task? + nonisolated(unsafe) private var loadTask: Task? // MARK: - Pre-computed Groupings (avoid computed property overhead) @@ -43,6 +43,10 @@ final class ScheduleViewModel { /// Debug info for troubleshooting missing games private(set) var diagnostics: ScheduleDiagnostics = ScheduleDiagnostics() + deinit { + loadTask?.cancel() + } + var hasFilters: Bool { selectedSports.count < Sport.supported.count || !searchText.isEmpty } diff --git a/SportsTime/Features/Trip/Views/AddItem/PlaceSearchSheet.swift b/SportsTime/Features/Trip/Views/AddItem/PlaceSearchSheet.swift index c9f6ba7..600ee2d 100644 --- a/SportsTime/Features/Trip/Views/AddItem/PlaceSearchSheet.swift +++ b/SportsTime/Features/Trip/Views/AddItem/PlaceSearchSheet.swift @@ -468,6 +468,8 @@ private struct PlaceRow: View { #Preview { PlaceSearchSheet { place in + #if DEBUG print("Selected: \(place.name ?? "unknown")") + #endif } } diff --git a/SportsTime/Features/Trip/Views/AddItem/QuickAddItemSheet.swift b/SportsTime/Features/Trip/Views/AddItem/QuickAddItemSheet.swift index ed979e9..eadd536 100644 --- a/SportsTime/Features/Trip/Views/AddItem/QuickAddItemSheet.swift +++ b/SportsTime/Features/Trip/Views/AddItem/QuickAddItemSheet.swift @@ -734,7 +734,9 @@ private struct PressableStyle: ButtonStyle { day: 1, existingItem: nil ) { item in + #if DEBUG print("Saved: \(item)") + #endif } .preferredColorScheme(.light) } @@ -745,7 +747,9 @@ private struct PressableStyle: ButtonStyle { day: 3, existingItem: nil ) { item in + #if DEBUG print("Saved: \(item)") + #endif } .preferredColorScheme(.dark) } @@ -770,6 +774,8 @@ private struct PressableStyle: ButtonStyle { day: existing.day, existingItem: existing ) { item in + #if DEBUG print("Updated: \(item)") + #endif } } diff --git a/SportsTime/Features/Trip/Views/ItineraryReorderingLogic.swift b/SportsTime/Features/Trip/Views/ItineraryReorderingLogic.swift index 836275d..2e54483 100644 --- a/SportsTime/Features/Trip/Views/ItineraryReorderingLogic.swift +++ b/SportsTime/Features/Trip/Views/ItineraryReorderingLogic.swift @@ -83,7 +83,9 @@ enum ItineraryReorderingLogic { // nil is valid during initial display before travel is persisted. let lookedUp = findTravelSortOrder(segment) sortOrder = lookedUp ?? defaultTravelSortOrder + #if DEBUG print("📋 [flattenDays] Travel \(segment.fromLocation.name)->\(segment.toLocation.name) on day \(day.dayNumber): lookedUp=\(String(describing: lookedUp)), using sortOrder=\(sortOrder)") + #endif case .games, .dayHeader: // These item types are not movable and handled separately. @@ -319,6 +321,7 @@ enum ItineraryReorderingLogic { } // DEBUG: Log the row positions + #if DEBUG print("đŸ”ĸ [calculateSortOrder] row=\(row), day=\(day), gamesRow=\(String(describing: gamesRow))") print("đŸ”ĸ [calculateSortOrder] items around row:") for i in max(0, row - 2)...min(items.count - 1, row + 2) { @@ -326,6 +329,7 @@ enum ItineraryReorderingLogic { let gMarker = (gamesRow == i) ? " [GAMES]" : "" print("đŸ”ĸ \(marker) [\(i)] \(items[i])\(gMarker)") } + #endif // Strict region classification: // - row < gamesRow => before-games (negative sortOrder) @@ -333,10 +337,14 @@ enum ItineraryReorderingLogic { let isBeforeGames: Bool if let gr = gamesRow { isBeforeGames = row < gr + #if DEBUG print("đŸ”ĸ [calculateSortOrder] row(\(row)) < gamesRow(\(gr)) = \(isBeforeGames) → isBeforeGames=\(isBeforeGames)") + #endif } else { isBeforeGames = false // No games means everything is "after games" + #if DEBUG print("đŸ”ĸ [calculateSortOrder] No games on day \(day) → isBeforeGames=false") + #endif } /// Get sortOrder from a movable item (custom item or travel) @@ -442,7 +450,9 @@ enum ItineraryReorderingLogic { } } + #if DEBUG print("đŸ”ĸ [calculateSortOrder] RESULT: \(result) (isBeforeGames=\(isBeforeGames))") + #endif return result } diff --git a/SportsTime/Features/Trip/Views/ItineraryTableViewController.swift b/SportsTime/Features/Trip/Views/ItineraryTableViewController.swift index 006db92..e1521d3 100644 --- a/SportsTime/Features/Trip/Views/ItineraryTableViewController.swift +++ b/SportsTime/Features/Trip/Views/ItineraryTableViewController.swift @@ -869,6 +869,7 @@ final class ItineraryTableViewController: UITableViewController { let sortOrder = calculateSortOrder(at: destinationIndexPath.row) // DEBUG: Log the final state after insertion + #if DEBUG print("đŸŽ¯ [Drop] source=\(sourceIndexPath.row) → dest=\(destinationIndexPath.row)") print("đŸŽ¯ [Drop] flatItems around dest:") for i in max(0, destinationIndexPath.row - 2)...min(flatItems.count - 1, destinationIndexPath.row + 2) { @@ -876,6 +877,7 @@ final class ItineraryTableViewController: UITableViewController { print("đŸŽ¯ [Drop] \(marker) [\(i)] \(flatItems[i])") } print("đŸŽ¯ [Drop] Calculated day=\(destinationDay), sortOrder=\(sortOrder)") + #endif onCustomItemMoved?(customItem.id, destinationDay, sortOrder) diff --git a/SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift b/SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift index d716593..391445b 100644 --- a/SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift +++ b/SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift @@ -53,6 +53,10 @@ struct ItineraryTableViewWrapper: UIViewControllerRepresent class Coordinator { var headerHostingController: UIHostingController? + var lastStopCount: Int = 0 + var lastGameIDsHash: Int = 0 + var lastItemCount: Int = 0 + var lastOverrideCount: Int = 0 } func makeUIViewController(context: Context) -> ItineraryTableViewController { @@ -73,7 +77,9 @@ struct ItineraryTableViewWrapper: UIViewControllerRepresent // Pre-size the header view hostingController.view.translatesAutoresizingMaskIntoConstraints = false - let targetWidth = UIScreen.main.bounds.width + let targetWidth = UIApplication.shared.connectedScenes + .compactMap { ($0 as? UIWindowScene)?.screen.bounds.width } + .first ?? 390 let targetSize = CGSize(width: targetWidth, height: UIView.layoutFittingCompressedSize.height) let size = hostingController.view.systemLayoutSizeFitting(targetSize, withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel) hostingController.view.frame = CGRect(origin: .zero, size: CGSize(width: targetWidth, height: max(size.height, 450))) @@ -109,6 +115,25 @@ struct ItineraryTableViewWrapper: UIViewControllerRepresent // This avoids recreating the view hierarchy and prevents infinite loops context.coordinator.headerHostingController?.rootView = headerContent + // Diff inputs before rebuilding to avoid unnecessary reloads + let currentStopCount = trip.stops.count + let currentGameIDsHash = games.map(\.game.id).hashValue + let currentItemCount = itineraryItems.count + let currentOverrideCount = travelOverrides.count + + let coord = context.coordinator + guard currentStopCount != coord.lastStopCount || + currentGameIDsHash != coord.lastGameIDsHash || + currentItemCount != coord.lastItemCount || + currentOverrideCount != coord.lastOverrideCount else { + return + } + + coord.lastStopCount = currentStopCount + coord.lastGameIDsHash = currentGameIDsHash + coord.lastItemCount = currentItemCount + coord.lastOverrideCount = currentOverrideCount + let (days, validRanges, allItemsForConstraints, travelSegmentIndices) = buildItineraryData() controller.reloadData( days: days, diff --git a/SportsTime/Features/Trip/Views/TripDetailView.swift b/SportsTime/Features/Trip/Views/TripDetailView.swift index cdb3b97..c73cf43 100644 --- a/SportsTime/Features/Trip/Views/TripDetailView.swift +++ b/SportsTime/Features/Trip/Views/TripDetailView.swift @@ -197,12 +197,14 @@ struct TripDetailView: View { private func handleItineraryItemsChange(_ newItems: [ItineraryItem]) { draggedItem = nil dropTargetId = nil + #if DEBUG print("đŸ—ēī¸ [MapUpdate] itineraryItems changed, count: \(newItems.count)") for item in newItems { if item.isCustom, let info = item.customInfo, info.isMappable { print("đŸ—ēī¸ [MapUpdate] Mappable: \(info.title) on day \(item.day), sortOrder: \(item.sortOrder)") } } + #endif mapUpdateTask?.cancel() mapUpdateTask = Task { updateMapRegion() @@ -851,7 +853,9 @@ struct TripDetailView: View { updated.modifiedAt = Date() let title = item.customInfo?.title ?? "item" + #if DEBUG print("📍 [Move] Moving \(title) to day \(day), sortOrder: \(sortOrder)") + #endif // Update local state if let idx = itineraryItems.firstIndex(where: { $0.id == item.id }) { @@ -863,7 +867,9 @@ struct TripDetailView: View { // Sync to CloudKit (debounced) await ItineraryItemService.shared.updateItem(updated) + #if DEBUG print("✅ [Move] Synced \(title) with day: \(day), sortOrder: \(sortOrder)") + #endif } /// Recompute cached itinerary sections from current state. @@ -1389,7 +1395,9 @@ struct TripDetailView: View { // MARK: - Itinerary Items (Local-first persistence with CloudKit sync) private func loadItineraryItems() async { + #if DEBUG print("🔍 [ItineraryItems] Loading items for trip: \(trip.id)") + #endif // 1. Load from local SwiftData first (instant, works offline) let tripId = trip.id @@ -1398,7 +1406,9 @@ struct TripDetailView: View { ) let localItems = (try? modelContext.fetch(descriptor))?.compactMap(\.toItem) ?? [] if !localItems.isEmpty { + #if DEBUG print("✅ [ItineraryItems] Loaded \(localItems.count) items from local cache") + #endif itineraryItems = localItems extractTravelOverrides(from: localItems) } @@ -1406,7 +1416,9 @@ struct TripDetailView: View { // 2. Try CloudKit for latest data (background sync) do { let cloudItems = try await ItineraryItemService.shared.fetchItems(forTripId: trip.id) + #if DEBUG print("✅ [ItineraryItems] Loaded \(cloudItems.count) items from CloudKit") + #endif // Merge: use CloudKit as source of truth when available if !cloudItems.isEmpty || localItems.isEmpty { @@ -1415,7 +1427,9 @@ struct TripDetailView: View { syncLocalCache(with: cloudItems) } } catch { + #if DEBUG print("âš ī¸ [ItineraryItems] CloudKit fetch failed (using local cache): \(error)") + #endif } } @@ -1429,7 +1443,9 @@ struct TripDetailView: View { segIdx < trip.travelSegments.count { let segment = trip.travelSegments[segIdx] if !travelInfo.matches(segment: segment) { + #if DEBUG print("âš ī¸ [TravelOverrides] Mismatched travel cities for segment \(segIdx); using canonical segment cities") + #endif } let travelId = stableTravelAnchorId(segment, at: segIdx) overrides[travelId] = TravelOverride(day: item.day, sortOrder: item.sortOrder) @@ -1441,7 +1457,9 @@ struct TripDetailView: View { } guard matches.count == 1, let match = matches.first else { + #if DEBUG print("âš ī¸ [TravelOverrides] Ignoring ambiguous legacy travel override \(travelInfo.fromCity)->\(travelInfo.toCity)") + #endif continue } @@ -1452,7 +1470,9 @@ struct TripDetailView: View { } travelOverrides = overrides + #if DEBUG print("✅ [TravelOverrides] Extracted \(overrides.count) travel overrides (day + sortOrder)") + #endif } private func syncLocalCache(with items: [ItineraryItem]) { @@ -1473,7 +1493,9 @@ struct TripDetailView: View { private func saveItineraryItem(_ item: ItineraryItem) async { let isUpdate = itineraryItems.contains(where: { $0.id == item.id }) let title = item.customInfo?.title ?? "item" + #if DEBUG print("💾 [ItineraryItems] Saving item: '\(title)' (isUpdate: \(isUpdate))") + #endif // Update in-memory state immediately if isUpdate { @@ -1491,14 +1513,20 @@ struct TripDetailView: View { do { if isUpdate { await ItineraryItemService.shared.updateItem(item) + #if DEBUG print("✅ [ItineraryItems] Updated in CloudKit: \(title)") + #endif } else { _ = try await ItineraryItemService.shared.createItem(item) + #if DEBUG print("✅ [ItineraryItems] Created in CloudKit: \(title)") + #endif markLocalItemSynced(item.id) } } catch { + #if DEBUG print("âš ī¸ [ItineraryItems] CloudKit save failed (saved locally): \(error)") + #endif } } @@ -1535,7 +1563,9 @@ struct TripDetailView: View { private func deleteItineraryItem(_ item: ItineraryItem) async { let title = item.customInfo?.title ?? "item" + #if DEBUG print("đŸ—‘ī¸ [ItineraryItems] Deleting item: '\(title)'") + #endif // Remove from in-memory state itineraryItems.removeAll { $0.id == item.id } @@ -1553,9 +1583,13 @@ struct TripDetailView: View { // Delete from CloudKit do { try await ItineraryItemService.shared.deleteItem(item.id) + #if DEBUG print("✅ [ItineraryItems] Deleted from CloudKit") + #endif } catch { + #if DEBUG print("âš ī¸ [ItineraryItems] CloudKit delete failed (removed locally): \(error)") + #endif } } @@ -1632,12 +1666,16 @@ struct TripDetailView: View { } private func saveTravelDayOverride(travelAnchorId: String, displayDay: Int, sortOrder: Double) async { + #if DEBUG print("💾 [TravelOverrides] Saving override: \(travelAnchorId) -> day \(displayDay), sortOrder \(sortOrder)") + #endif guard let segmentIndex = Self.parseSegmentIndex(from: travelAnchorId), segmentIndex >= 0, segmentIndex < trip.travelSegments.count else { + #if DEBUG print("❌ [TravelOverrides] Invalid travel segment index in ID: \(travelAnchorId)") + #endif return } @@ -1676,14 +1714,18 @@ struct TripDetailView: View { _ = try await ItineraryItemService.shared.createItem(item) markLocalItemSynced(item.id) } catch { + #if DEBUG print("❌ [TravelOverrides] CloudKit save failed: \(error)") + #endif return } } + #if DEBUG if canonicalTravelId != travelAnchorId { print("â„šī¸ [TravelOverrides] Canonicalized travel ID to \(canonicalTravelId)") } print("✅ [TravelOverrides] Saved to CloudKit") + #endif } } diff --git a/SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift b/SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift index cd5214a..f8d4e67 100644 --- a/SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift +++ b/SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift @@ -203,7 +203,9 @@ struct TripWizardView: View { // Team-First mode: fetch ALL games for the season // ScenarioEPlanner will generate sliding windows across the full season games = try await AppDataProvider.shared.allGames(for: preferences.sports) + #if DEBUG print("🔍 TripWizard: Team-First mode - fetched \(games.count) games for \(preferences.sports)") + #endif } else if viewModel.planningMode == .gameFirst && !viewModel.selectedGameIds.isEmpty { // Fetch all games for the selected sports within the UI date range // GamePickerStep already set viewModel.startDate/endDate to a 7-day span diff --git a/SportsTime/Planning/Engine/GameDAGRouter.swift b/SportsTime/Planning/Engine/GameDAGRouter.swift index 3a8fc12..aa20a33 100644 --- a/SportsTime/Planning/Engine/GameDAGRouter.swift +++ b/SportsTime/Planning/Engine/GameDAGRouter.swift @@ -246,7 +246,9 @@ enum GameDAGRouter { // Step 6: Final diversity selection let finalRoutes = selectDiverseRoutes(routesWithAnchors, stadiums: stadiums, maxCount: maxOptions) + #if DEBUG print("🔍 DAG: Input games=\(games.count), beam=\(beam.count), withAnchors=\(routesWithAnchors.count), final=\(finalRoutes.count)") + #endif return finalRoutes } @@ -586,10 +588,12 @@ enum GameDAGRouter { let feasible = drivingHours <= maxDrivingHoursAvailable && drivingHours <= availableHours // Debug output for rejected transitions + #if DEBUG if !feasible && drivingHours > 10 { print("🔍 DAG canTransition REJECTED: \(fromStadium.city) → \(toStadium.city)") print("🔍 drivingHours=\(String(format: "%.1f", drivingHours)), daysBetween=\(daysBetween), maxAvailable=\(String(format: "%.1f", maxDrivingHoursAvailable)), availableHours=\(String(format: "%.1f", availableHours))") } + #endif return feasible } diff --git a/SportsTime/Planning/Engine/ScenarioAPlanner.swift b/SportsTime/Planning/Engine/ScenarioAPlanner.swift index 2112994..e679026 100644 --- a/SportsTime/Planning/Engine/ScenarioAPlanner.swift +++ b/SportsTime/Planning/Engine/ScenarioAPlanner.swift @@ -192,12 +192,14 @@ final class ScenarioAPlanner: ScenarioPlanner { } } + #if DEBUG print("🔍 ScenarioA: filteredGames=\(filteredGames.count), validRoutes=\(validRoutes.count)") if let firstRoute = validRoutes.first { print("🔍 ScenarioA: First route has \(firstRoute.count) games") let cities = firstRoute.compactMap { request.stadiums[$0.stadiumId]?.city } print("🔍 ScenarioA: Route cities: \(cities)") } + #endif if validRoutes.isEmpty { let noMustStopSatisfyingRoutes = !requiredMustStops.isEmpty @@ -233,13 +235,17 @@ final class ScenarioAPlanner: ScenarioPlanner { // Build stops for this route let stops = buildStops(from: routeGames, stadiums: request.stadiums) + #if DEBUG // Debug: show stops created from games let stopCities = stops.map { "\($0.city)(\($0.games.count)g)" } print("🔍 ScenarioA: Route \(index) - \(routeGames.count) games → \(stops.count) stops: \(stopCities)") + #endif guard !stops.isEmpty else { routesFailed += 1 + #if DEBUG print("âš ī¸ ScenarioA: Route \(index) - buildStops returned empty") + #endif continue } @@ -250,7 +256,9 @@ final class ScenarioAPlanner: ScenarioPlanner { ) else { // This route fails driving constraints, skip it routesFailed += 1 + #if DEBUG print("âš ī¸ ScenarioA: Route \(index) - ItineraryBuilder.build failed") + #endif continue } @@ -291,21 +299,25 @@ final class ScenarioAPlanner: ScenarioPlanner { // Sort and rank based on leisure level let leisureLevel = request.preferences.leisureLevel + #if DEBUG // Debug: show all options before sorting print("🔍 ScenarioA: \(itineraryOptions.count) itinerary options before sorting:") for (i, opt) in itineraryOptions.enumerated() { print(" Option \(i): \(opt.stops.count) stops, \(opt.totalGames) games, \(String(format: "%.1f", opt.totalDrivingHours))h driving") } + #endif let rankedOptions = ItineraryOption.sortByLeisure( itineraryOptions, leisureLevel: leisureLevel ) + #if DEBUG print("🔍 ScenarioA: Returning \(rankedOptions.count) options after sorting") if let first = rankedOptions.first { print("🔍 ScenarioA: First option has \(first.stops.count) stops") } + #endif return .success(rankedOptions) } @@ -479,10 +491,12 @@ final class ScenarioAPlanner: ScenarioPlanner { } } + #if DEBUG print("🔍 ScenarioA Regional: Partitioned \(games.count) games into \(gamesByRegion.count) regions") for (region, regionGames) in gamesByRegion { print(" \(region.shortName): \(regionGames.count) games") } + #endif // Run beam search for each region var allRoutes: [[Game]] = [] @@ -497,7 +511,9 @@ final class ScenarioAPlanner: ScenarioPlanner { stopBuilder: buildStops ) + #if DEBUG print("🔍 ScenarioA Regional: \(region.shortName) produced \(regionRoutes.count) routes") + #endif allRoutes.append(contentsOf: regionRoutes) } diff --git a/SportsTime/Planning/Engine/ScenarioBPlanner.swift b/SportsTime/Planning/Engine/ScenarioBPlanner.swift index 0e64dec..7acf197 100644 --- a/SportsTime/Planning/Engine/ScenarioBPlanner.swift +++ b/SportsTime/Planning/Engine/ScenarioBPlanner.swift @@ -468,7 +468,9 @@ final class ScenarioBPlanner: ScenarioPlanner { } } + #if DEBUG print("🔍 ScenarioB Regional: Anchor games in regions: \(anchorRegions.map { $0.shortName })") + #endif // Run beam search for each region that has anchor games // (Other regions without anchor games would produce routes that don't satisfy anchors) @@ -490,7 +492,9 @@ final class ScenarioBPlanner: ScenarioPlanner { stopBuilder: buildStops ) + #if DEBUG print("🔍 ScenarioB Regional: \(region.shortName) produced \(regionRoutes.count) routes") + #endif allRoutes.append(contentsOf: regionRoutes) } diff --git a/SportsTime/Planning/Engine/ScenarioDPlanner.swift b/SportsTime/Planning/Engine/ScenarioDPlanner.swift index 9a7b29f..febad1a 100644 --- a/SportsTime/Planning/Engine/ScenarioDPlanner.swift +++ b/SportsTime/Planning/Engine/ScenarioDPlanner.swift @@ -97,6 +97,7 @@ final class ScenarioDPlanner: ScenarioPlanner { // ────────────────────────────────────────────────────────────────── let teamGames = filterToTeam(request.allGames, teamId: teamId) + #if DEBUG print("🔍 ScenarioD Step 3: allGames=\(request.allGames.count), teamGames=\(teamGames.count)") print("🔍 ScenarioD: Looking for teamId=\(teamId)") for game in teamGames.prefix(20) { @@ -104,6 +105,7 @@ final class ScenarioDPlanner: ScenarioPlanner { let isHome = game.homeTeamId == teamId print("🔍 Game: \(stadium?.city ?? "?") on \(game.gameDate) (\(isHome ? "HOME" : "AWAY"))") } + #endif if teamGames.isEmpty { return .failure( @@ -124,15 +126,19 @@ final class ScenarioDPlanner: ScenarioPlanner { // Step 4: Apply date range and region filters // ────────────────────────────────────────────────────────────────── let selectedRegions = request.preferences.selectedRegions + #if DEBUG print("🔍 ScenarioD Step 4: dateRange=\(dateRange.start) to \(dateRange.end), selectedRegions=\(selectedRegions)") + #endif let filteredGames = teamGames .filter { game in // Must be in date range let inDateRange = dateRange.contains(game.startTime) if !inDateRange { + #if DEBUG let stadium = request.stadiums[game.stadiumId] print("🔍 FILTERED OUT (date): \(stadium?.city ?? "?") on \(game.gameDate) - startTime=\(game.startTime)") + #endif return false } @@ -141,20 +147,24 @@ final class ScenarioDPlanner: ScenarioPlanner { guard let stadium = request.stadiums[game.stadiumId] else { return false } let gameRegion = Region.classify(longitude: stadium.coordinate.longitude) let inRegion = selectedRegions.contains(gameRegion) + #if DEBUG if !inRegion { print("🔍 FILTERED OUT (region): \(stadium.city) on \(game.gameDate) - region=\(gameRegion)") } + #endif return inRegion } return true } .sorted { $0.startTime < $1.startTime } + #if DEBUG print("🔍 ScenarioD Step 4 result: \(filteredGames.count) games after date/region filter") for game in filteredGames { let stadium = request.stadiums[game.stadiumId] print("🔍 Kept: \(stadium?.city ?? "?") on \(game.gameDate)") } + #endif if filteredGames.isEmpty { return .failure( @@ -185,15 +195,19 @@ final class ScenarioDPlanner: ScenarioPlanner { // July 27 if it makes the driving from Chicago feasible). let finalGames = filteredGames + #if DEBUG print("🔍 ScenarioD Step 5: Passing \(finalGames.count) games to router (allowRepeatCities=\(request.preferences.allowRepeatCities))") print("🔍 ScenarioD: teamGames=\(teamGames.count), filteredGames=\(filteredGames.count), finalGames=\(finalGames.count)") + #endif // ────────────────────────────────────────────────────────────────── // Step 6: Find valid routes using DAG router // ────────────────────────────────────────────────────────────────── // Follow Team mode typically has fewer games than Scenario A, // so we can be more exhaustive in route finding. + #if DEBUG print("🔍 ScenarioD Step 6: Finding routes from \(finalGames.count) games") + #endif var validRoutes: [[Game]] = [] @@ -203,18 +217,22 @@ final class ScenarioDPlanner: ScenarioPlanner { allowRepeatCities: request.preferences.allowRepeatCities, stopBuilder: buildStops ) + #if DEBUG print("🔍 ScenarioD Step 6: GameDAGRouter returned \(globalRoutes.count) routes") for (i, route) in globalRoutes.prefix(5).enumerated() { let cities = route.compactMap { request.stadiums[$0.stadiumId]?.city }.joined(separator: " → ") print("🔍 Route \(i+1): \(route.count) games - \(cities)") } + #endif validRoutes.append(contentsOf: globalRoutes) // Deduplicate routes validRoutes = deduplicateRoutes(validRoutes) + #if DEBUG print("🔍 ScenarioD Step 6 after dedup: \(validRoutes.count) valid routes") + #endif if validRoutes.isEmpty { return .failure( @@ -292,7 +310,9 @@ final class ScenarioDPlanner: ScenarioPlanner { leisureLevel: leisureLevel ) + #if DEBUG print("🔍 ScenarioD: Returning \(rankedOptions.count) options") + #endif return .success(rankedOptions) } @@ -315,22 +335,32 @@ final class ScenarioDPlanner: ScenarioPlanner { stadiums: [String: Stadium] ) -> [Game] { guard !allowRepeat else { + #if DEBUG print("🔍 applyRepeatCityFilter: allowRepeat=true, returning all \(games.count) games") + #endif return games } + #if DEBUG print("🔍 applyRepeatCityFilter: allowRepeat=false, filtering duplicates") + #endif var seenCities: Set = [] return games.filter { game in guard let stadium = stadiums[game.stadiumId] else { + #if DEBUG print("🔍 Game \(game.id): NO STADIUM FOUND - filtered out") + #endif return false } if seenCities.contains(stadium.city) { + #if DEBUG print("🔍 Game in \(stadium.city) on \(game.gameDate): DUPLICATE CITY - filtered out") + #endif return false } + #if DEBUG print("🔍 Game in \(stadium.city) on \(game.gameDate): KEPT (first in this city)") + #endif seenCities.insert(stadium.city) return true } diff --git a/SportsTime/Planning/Engine/ScenarioEPlanner.swift b/SportsTime/Planning/Engine/ScenarioEPlanner.swift index d4ef39e..f06ca93 100644 --- a/SportsTime/Planning/Engine/ScenarioEPlanner.swift +++ b/SportsTime/Planning/Engine/ScenarioEPlanner.swift @@ -117,17 +117,21 @@ final class ScenarioEPlanner: ScenarioPlanner { } } + #if DEBUG print("🔍 ScenarioE Step 2: Found \(allHomeGames.count) total home games across \(selectedTeamIds.count) teams") for (teamId, games) in homeGamesByTeam { let teamName = request.teams[teamId]?.fullName ?? teamId print("🔍 \(teamName): \(games.count) home games") } + #endif // ────────────────────────────────────────────────────────────────── // Step 3: Generate sliding windows across the season // ────────────────────────────────────────────────────────────────── let windowDuration = request.preferences.teamFirstMaxDays + #if DEBUG print("🔍 ScenarioE Step 3: Window duration = \(windowDuration) days") + #endif let validWindows = generateValidWindows( homeGamesByTeam: homeGamesByTeam, @@ -150,13 +154,17 @@ final class ScenarioEPlanner: ScenarioPlanner { ) } + #if DEBUG print("🔍 ScenarioE Step 3: Found \(validWindows.count) valid windows") + #endif // Sample windows if too many exist let windowsToEvaluate: [DateInterval] if validWindows.count > maxWindowsToEvaluate { windowsToEvaluate = sampleWindows(validWindows, count: maxWindowsToEvaluate) + #if DEBUG print("🔍 ScenarioE Step 3: Sampled down to \(windowsToEvaluate.count) windows") + #endif } else { windowsToEvaluate = validWindows } @@ -280,7 +288,9 @@ final class ScenarioEPlanner: ScenarioPlanner { // Early exit if we have enough options if allItineraryOptions.count >= maxResultsToReturn * 5 { + #if DEBUG print("🔍 ScenarioE: Early exit at window \(windowIndex + 1) with \(allItineraryOptions.count) options") + #endif break } } @@ -331,7 +341,9 @@ final class ScenarioEPlanner: ScenarioPlanner { ) } + #if DEBUG print("🔍 ScenarioE: Returning \(rankedOptions.count) options") + #endif return .success(rankedOptions) } diff --git a/SportsTime/Planning/Engine/ScenarioPlanner.swift b/SportsTime/Planning/Engine/ScenarioPlanner.swift index 6e5ab50..4129b70 100644 --- a/SportsTime/Planning/Engine/ScenarioPlanner.swift +++ b/SportsTime/Planning/Engine/ScenarioPlanner.swift @@ -43,6 +43,7 @@ enum ScenarioPlannerFactory { /// - Both start and end locations → ScenarioCPlanner /// - Otherwise → ScenarioAPlanner static func planner(for request: PlanningRequest) -> ScenarioPlanner { + #if DEBUG print("🔍 ScenarioPlannerFactory: Selecting planner...") print(" - planningMode: \(request.preferences.planningMode)") print(" - selectedTeamIds.count: \(request.preferences.selectedTeamIds.count)") @@ -50,11 +51,14 @@ enum ScenarioPlannerFactory { print(" - selectedGames.count: \(request.selectedGames.count)") print(" - startLocation: \(request.startLocation?.name ?? "nil")") print(" - endLocation: \(request.endLocation?.name ?? "nil")") + #endif // Scenario E: Team-First mode - user selects teams, finds optimal trip windows if request.preferences.planningMode == .teamFirst && request.preferences.selectedTeamIds.count >= 2 { + #if DEBUG print("🔍 ScenarioPlannerFactory: → ScenarioEPlanner (team-first)") + #endif return ScenarioEPlanner() } @@ -62,30 +66,40 @@ enum ScenarioPlannerFactory { if request.preferences.planningMode == .teamFirst && request.preferences.selectedTeamIds.count == 1 { // Single team in teamFirst mode → treat as follow-team + #if DEBUG print("🔍 ScenarioPlannerFactory: → ScenarioDPlanner (team-first single team)") + #endif return ScenarioDPlanner() } // Scenario D: User wants to follow a specific team if request.preferences.followTeamId != nil { + #if DEBUG print("🔍 ScenarioPlannerFactory: → ScenarioDPlanner (follow team)") + #endif return ScenarioDPlanner() } // Scenario B: User selected specific games if !request.selectedGames.isEmpty { + #if DEBUG print("🔍 ScenarioPlannerFactory: → ScenarioBPlanner (selected games)") + #endif return ScenarioBPlanner() } // Scenario C: User specified start and end locations if request.startLocation != nil && request.endLocation != nil { + #if DEBUG print("🔍 ScenarioPlannerFactory: → ScenarioCPlanner (start/end locations)") + #endif return ScenarioCPlanner() } // Scenario A: Date range only (default) + #if DEBUG print("🔍 ScenarioPlannerFactory: → ScenarioAPlanner (default/date range)") + #endif return ScenarioAPlanner() } diff --git a/SportsTime/SportsTimeApp.swift b/SportsTime/SportsTimeApp.swift index 7002af9..e695bb8 100644 --- a/SportsTime/SportsTimeApp.swift +++ b/SportsTime/SportsTimeApp.swift @@ -15,9 +15,6 @@ struct SportsTimeApp: App { /// App delegate for handling push notifications @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate - /// Task that listens for StoreKit transaction updates - private var transactionListener: Task? - init() { // UI Test Mode: disable animations and force classic style for deterministic tests if ProcessInfo.isUITesting || ProcessInfo.shouldDisableAnimations { @@ -37,7 +34,7 @@ struct SportsTimeApp: App { // Start listening for transactions immediately if !ProcessInfo.isUITesting { - transactionListener = StoreManager.shared.listenForTransactions() + StoreManager.shared.startListeningForTransactions() } } @@ -127,7 +124,7 @@ struct BootstrappedContentView: View { } .sheet(item: $deepLinkHandler.pendingPollShareCode) { code in NavigationStack { - PollDetailView(shareCode: code) + PollDetailView(shareCode: code.value) } } .alert("Error", isPresented: .constant(deepLinkHandler.error != nil)) { @@ -176,7 +173,9 @@ struct BootstrappedContentView: View { @MainActor private func performBootstrap() async { + #if DEBUG print("🚀 [BOOT] Starting app bootstrap...") + #endif isBootstrapping = true bootstrapError = nil @@ -186,7 +185,9 @@ struct BootstrappedContentView: View { do { // 0. UI Test Mode: reset user data if requested if ProcessInfo.shouldResetState { + #if DEBUG print("🚀 [BOOT] Step 0: Resetting user data for UI tests...") + #endif try context.delete(model: SavedTrip.self) try context.delete(model: StadiumVisit.self) try context.delete(model: Achievement.self) @@ -197,59 +198,81 @@ struct BootstrappedContentView: View { } // 1. Bootstrap from bundled JSON if first launch (no data exists) + #if DEBUG print("🚀 [BOOT] Step 1: Checking if bootstrap needed...") + #endif try await bootstrapService.bootstrapIfNeeded(context: context) // 2. Configure DataProvider with SwiftData context + #if DEBUG print("🚀 [BOOT] Step 2: Configuring DataProvider...") + #endif AppDataProvider.shared.configure(with: context) // 3. Configure BackgroundSyncManager with model container + #if DEBUG print("🚀 [BOOT] Step 3: Configuring BackgroundSyncManager...") + #endif BackgroundSyncManager.shared.configure(with: modelContainer) // 4. Load data from SwiftData into memory + #if DEBUG print("🚀 [BOOT] Step 4: Loading initial data from SwiftData...") + #endif await AppDataProvider.shared.loadInitialData() if let loadError = AppDataProvider.shared.error { throw loadError } + #if DEBUG print("🚀 [BOOT] Loaded \(AppDataProvider.shared.teams.count) teams") print("🚀 [BOOT] Loaded \(AppDataProvider.shared.stadiums.count) stadiums") + #endif // 5. Load store products and entitlements if ProcessInfo.isUITesting { - print("🚀 [BOOT] Step 5: UI Test Mode — forcing Pro, skipping StoreKit") #if DEBUG + print("🚀 [BOOT] Step 5: UI Test Mode — forcing Pro, skipping StoreKit") StoreManager.shared.debugProOverride = true #endif } else { + #if DEBUG print("🚀 [BOOT] Step 5: Loading store products...") + #endif await StoreManager.shared.loadProducts() await StoreManager.shared.updateEntitlements() } // 6. Start network monitoring and wire up sync callback if !ProcessInfo.isUITesting { + #if DEBUG print("🚀 [BOOT] Step 6: Starting network monitoring...") + #endif NetworkMonitor.shared.onSyncNeeded = { await BackgroundSyncManager.shared.triggerSyncFromNetworkRestoration() } NetworkMonitor.shared.startMonitoring() } else { + #if DEBUG print("🚀 [BOOT] Step 6: UI Test Mode — skipping network monitoring") + #endif } // 7. Configure analytics if !ProcessInfo.isUITesting { + #if DEBUG print("🚀 [BOOT] Step 7: Configuring analytics...") + #endif AnalyticsManager.shared.configure() } else { + #if DEBUG print("🚀 [BOOT] Step 7: UI Test Mode — skipping analytics") + #endif } // 8. App is now usable + #if DEBUG print("🚀 [BOOT] Step 8: Bootstrap complete - app ready") + #endif isBootstrapping = false UIAccessibility.post(notification: .screenChanged, argument: nil) @@ -264,7 +287,9 @@ struct BootstrappedContentView: View { } // 10. Background: Try to refresh from CloudKit (non-blocking) + #if DEBUG print("🚀 [BOOT] Step 10: Starting background CloudKit sync...") + #endif Task(priority: .background) { await self.performBackgroundSync(context: self.modelContainer.mainContext) await MainActor.run { @@ -272,11 +297,15 @@ struct BootstrappedContentView: View { } } } else { + #if DEBUG print("🚀 [BOOT] Steps 9-10: UI Test Mode — skipping CloudKit sync") + #endif hasCompletedInitialSync = true } } catch { + #if DEBUG print("❌ [BOOT] Bootstrap failed: \(error.localizedDescription)") + #endif bootstrapError = error isBootstrapping = false } @@ -286,7 +315,6 @@ struct BootstrappedContentView: View { private func performBackgroundSync(context: ModelContext) async { let log = SyncLogger.shared log.log("🔄 [SYNC] Starting background sync...") - AccessibilityAnnouncer.announce("Sync started.") // Log diagnostic info for debugging CloudKit container issues let bundleId = Bundle.main.bundleIdentifier ?? "unknown" @@ -341,7 +369,9 @@ struct BootstrappedContentView: View { } else { log.log("🔄 [SYNC] No updates - skipping DataProvider reload") } - AccessibilityAnnouncer.announce("Sync complete. Updated \(result.totalUpdated) records.") + if result.totalUpdated > 0 { + AccessibilityAnnouncer.announce("Sync complete. Updated \(result.totalUpdated) records.") + } } catch CanonicalSyncService.SyncError.cloudKitUnavailable { log.log("❌ [SYNC] CloudKit unavailable - using local data only") AccessibilityAnnouncer.announce("Cloud sync unavailable. Using local data.") @@ -353,12 +383,6 @@ struct BootstrappedContentView: View { } -// MARK: - String Identifiable for Sheet - -extension String: @retroactive Identifiable { - public var id: String { self } -} - // MARK: - Bootstrap Loading View struct BootstrapLoadingView: View { @@ -370,6 +394,7 @@ struct BootstrapLoadingView: View { .font(.headline) .foregroundStyle(.secondary) } + .accessibilityElement(children: .combine) } }