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