From 3e778473e6da663439827a398ef49062768a55ac Mon Sep 17 00:00:00 2001 From: Trey t Date: Fri, 9 Jan 2026 11:42:27 -0600 Subject: [PATCH] Fix coast-to-coast trips and improve itinerary display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix same-day different-city validation in C2C routes (no more impossible games like Detroit 7:30pm AND Milwaukee 8pm on the same day) - Cap C2C trips at 14 days max with 3 middle stops, prefer shortest routes - Add sport icon and name to game rows in trip itinerary - Add horizontal scroll to route dots in suggested trip cards - Allow swipe-to-dismiss on home sheet (trip planner still blocks) - Generate travel segments for suggested trips - Increase DAG route lookahead to 5 days for multi-day drives πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../Services/SuggestedTripsGenerator.swift | 475 +++++++++++++++--- .../Core/Theme/AnimatedComponents.swift | 37 +- SportsTime/Features/Home/Views/HomeView.swift | 1 - .../Home/Views/SuggestedTripCard.swift | 56 ++- .../Features/Trip/Views/TripDetailView.swift | 30 +- .../Planning/Engine/GameDAGRouter.swift | 26 +- .../Planning/Engine/ScenarioAPlanner.swift | 26 + 7 files changed, 531 insertions(+), 120 deletions(-) diff --git a/SportsTime/Core/Services/SuggestedTripsGenerator.swift b/SportsTime/Core/Services/SuggestedTripsGenerator.swift index ff6bfc5..703946f 100644 --- a/SportsTime/Core/Services/SuggestedTripsGenerator.swift +++ b/SportsTime/Core/Services/SuggestedTripsGenerator.swift @@ -7,6 +7,7 @@ import Foundation import SwiftUI +import CoreLocation // MARK: - Suggested Trip Model @@ -304,7 +305,7 @@ final class SuggestedTripsGenerator { ) -> SuggestedTrip? { let excludeIds = Set(excludeGames.map { $0.id }) - var availableGames = games.filter { !excludeIds.contains($0.id) } + let availableGames = games.filter { !excludeIds.contains($0.id) } guard !availableGames.isEmpty else { return nil } @@ -315,48 +316,106 @@ final class SuggestedTripsGenerator { gamesByRegion[stadium.region, default: []].append(game) } - // Ensure we have games in at least 2 regions (ideally all 3) - let regionsWithGames = [Region.east, Region.central, Region.west].filter { - gamesByRegion[$0]?.isEmpty == false + print("πŸ“ CrossCountry: Games by region - East: \(gamesByRegion[.east]?.count ?? 0), Central: \(gamesByRegion[.central]?.count ?? 0), West: \(gamesByRegion[.west]?.count ?? 0)") + + // REQUIREMENT: Must have games in BOTH East AND West for a true coast-to-coast + guard let eastGames = gamesByRegion[.east], !eastGames.isEmpty, + let westGames = gamesByRegion[.west], !westGames.isEmpty else { + print("❌ CrossCountry: Need games in BOTH East and West regions") + return nil } - guard regionsWithGames.count >= 2 else { return nil } + let centralGames = gamesByRegion[.central] ?? [] + let calendar = Calendar.current - // Select games from each region to build a cross-country route - var selectedGames: [Game] = [] + // Try both directions: Eastβ†’West and Westβ†’East + let eastToWest = buildCoastToCoastRoute( + startGames: eastGames, + middleGames: centralGames, + endGames: westGames, + stadiums: stadiums, + calendar: calendar + ) - for region in regionsWithGames { - guard let regionGames = gamesByRegion[region] else { continue } - // Pick 2-3 games per region - let count = min(regionGames.count, Int.random(in: 2...3)) - let picked = Array(regionGames.shuffled().prefix(count)) - selectedGames.append(contentsOf: picked) + let westToEast = buildCoastToCoastRoute( + startGames: westGames, + middleGames: centralGames, + endGames: eastGames, + stadiums: stadiums, + calendar: calendar + ) + + // Pick the better route (more games) + var selectedGames: [Game] + if eastToWest.count >= westToEast.count && eastToWest.count >= 3 { + selectedGames = eastToWest + print("🧭 CrossCountry: Using Eastβ†’West route") + } else if westToEast.count >= 3 { + selectedGames = westToEast + print("🧭 CrossCountry: Using Westβ†’East route") + } else { + print("❌ CrossCountry: No valid coast-to-coast route found (Eβ†’W: \(eastToWest.count), Wβ†’E: \(westToEast.count))") + return nil } - // Sort by date - selectedGames.sort { $0.dateTime < $1.dateTime } - - // Limit to reasonable number (8-10 max) - if selectedGames.count > 10 { - selectedGames = Array(selectedGames.prefix(10)) + // Validate the route spans both coasts + let routeRegions = Set(selectedGames.compactMap { stadiums[$0.stadiumId]?.region }) + guard routeRegions.contains(.east) && routeRegions.contains(.west) else { + print("❌ CrossCountry: Route doesn't span both coasts: \(routeRegions)") + return nil } - guard selectedGames.count >= 4 else { return nil } + // Debug: show selected cities and their regions + let cityDetails = selectedGames.compactMap { game -> String? in + guard let stadium = stadiums[game.stadiumId] else { return nil } + let dateStr = game.dateTime.formatted(date: .abbreviated, time: .omitted) + return "\(stadium.city)(\(stadium.region.rawValue.prefix(1)),\(dateStr))" + } + print("βœ… CrossCountry: Selected \(selectedGames.count) games: \(cityDetails.joined(separator: " β†’ "))") - // Ensure enough unique cities for a true cross-country trip - let uniqueCities = Set(selectedGames.compactMap { stadiums[$0.stadiumId]?.city }) - guard uniqueCities.count >= 3 else { return nil } - - // Calculate trip dates + // Build trip directly from selected games (bypass planning engine) guard let firstGame = selectedGames.first, let lastGame = selectedGames.last else { return nil } - let tripStartDate = Calendar.current.date(byAdding: .day, value: -1, to: firstGame.dateTime) ?? firstGame.dateTime - let tripEndDate = Calendar.current.date(byAdding: .day, value: 1, to: lastGame.dateTime) ?? lastGame.dateTime + let tripStartDate = calendar.date(byAdding: .day, value: -1, to: firstGame.dateTime) ?? firstGame.dateTime + let tripEndDate = calendar.date(byAdding: .day, value: 1, to: lastGame.dateTime) ?? lastGame.dateTime let sports = Set(selectedGames.map { $0.sport }) - // Build planning request + // Build stops by grouping consecutive games at the same stadium + var tripStops: [TripStop] = [] + var currentStadiumId: UUID? = nil + var currentGames: [Game] = [] + + for game in selectedGames { + if game.stadiumId == currentStadiumId { + currentGames.append(game) + } else { + // Finalize previous stop + if let stadiumId = currentStadiumId, !currentGames.isEmpty { + if let stop = buildTripStop(from: currentGames, stadiumId: stadiumId, stadiums: stadiums, stopNumber: tripStops.count + 1) { + tripStops.append(stop) + } + } + currentStadiumId = game.stadiumId + currentGames = [game] + } + } + // Don't forget the last group + if let stadiumId = currentStadiumId, !currentGames.isEmpty { + if let stop = buildTripStop(from: currentGames, stadiumId: stadiumId, stadiums: stadiums, stopNumber: tripStops.count + 1) { + tripStops.append(stop) + } + } + + guard tripStops.count >= 3 else { + print("❌ CrossCountry: Only \(tripStops.count) stops built (need at least 3)") + return nil + } + + print("βœ… CrossCountry: Built trip with \(tripStops.count) stops") + + // Build the trip let preferences = TripPreferences( planningMode: .dateRange, sports: sports, @@ -366,56 +425,91 @@ final class SuggestedTripsGenerator { maxTripOptions: 1 ) - let request = PlanningRequest( + // Generate travel segments between stops + let travelSegments = buildTravelSegments(from: tripStops, stadiums: stadiums) + + // Calculate totals from travel segments + let totalDistanceMeters = travelSegments.reduce(0) { $0 + $1.distanceMeters } + let totalDrivingSeconds = travelSegments.reduce(0) { $0 + $1.durationSeconds } + + let trip = Trip( + name: generateTripName(from: tripStops), preferences: preferences, - availableGames: selectedGames, - teams: teams, - stadiums: stadiums + stops: tripStops, + travelSegments: travelSegments, + totalGames: selectedGames.count, + totalDistanceMeters: totalDistanceMeters, + totalDrivingSeconds: totalDrivingSeconds ) - // Run planning engine - let result = planningEngine.planItineraries(request: request) + // Build richGames dictionary + let richGames = buildRichGames(from: selectedGames, teams: teams, stadiums: stadiums) - switch result { - case .success(let options): - guard let option = options.first else { return nil } + return SuggestedTrip( + id: UUID(), + region: .crossCountry, + isSingleSport: sports.count == 1, + trip: trip, + richGames: richGames, + sports: sports + ) + } - let trip = convertToTrip(option: option, preferences: preferences) + /// Builds a TripStop from a group of games at the same stadium + private func buildTripStop(from games: [Game], stadiumId: UUID, stadiums: [UUID: Stadium], stopNumber: Int) -> TripStop? { + guard !games.isEmpty else { return nil } - // Build richGames dictionary - let richGames = buildRichGames(from: selectedGames, teams: teams, stadiums: stadiums) + let sortedGames = games.sorted { $0.dateTime < $1.dateTime } + let stadium = stadiums[stadiumId] + let city = stadium?.city ?? "Unknown" + let state = stadium?.state ?? "" + let coordinate = stadium?.coordinate - // Validate the final trip meets cross-country requirements: - // - At least 4 stops (cities) - // - At least 2 different regions - guard trip.stops.count >= 4 else { return nil } + let lastGameDate = sortedGames.last?.gameDate ?? Date() + let departureDate = Calendar.current.date(byAdding: .day, value: 1, to: lastGameDate) ?? lastGameDate - let stopsWithRegions = trip.stops.compactMap { stop -> Region? in - guard let stadium = stadiums.values.first(where: { $0.city == stop.city }) else { return nil } - return stadium.region + return TripStop( + stopNumber: stopNumber, + city: city, + state: state, + coordinate: coordinate, + arrivalDate: sortedGames.first?.gameDate ?? Date(), + departureDate: departureDate, + games: sortedGames.map { $0.id }, + isRestDay: false + ) + } + + /// Builds travel segments between consecutive stops using TravelEstimator + private func buildTravelSegments(from stops: [TripStop], stadiums: [UUID: Stadium]) -> [TravelSegment] { + guard stops.count >= 2 else { return [] } + + var segments: [TravelSegment] = [] + let constraints = DrivingConstraints.default + + for i in 0..<(stops.count - 1) { + let fromStop = stops[i] + let toStop = stops[i + 1] + + // Get coordinates from stops or from stadium lookup + let fromCoord = fromStop.coordinate ?? stadiums.values.first { $0.city == fromStop.city }?.coordinate + let toCoord = toStop.coordinate ?? stadiums.values.first { $0.city == toStop.city }?.coordinate + + let fromLocation = LocationInput(name: fromStop.city, coordinate: fromCoord) + let toLocation = LocationInput(name: toStop.city, coordinate: toCoord) + + // Use TravelEstimator for consistent distance/time calculations + if let segment = TravelEstimator.estimate(from: fromLocation, to: toLocation, constraints: constraints) { + segments.append(segment) } - let uniqueRegions = Set(stopsWithRegions) - guard uniqueRegions.count >= 2 else { return nil } - - // Compute sports from games actually in the trip (not all selectedGames) - let gameIdsInTrip = Set(trip.stops.flatMap { $0.games }) - let actualSports = Set(gameIdsInTrip.compactMap { richGames[$0]?.game.sport }) - - return SuggestedTrip( - id: UUID(), - region: .crossCountry, - isSingleSport: actualSports.count == 1, - trip: trip, - richGames: richGames, - sports: actualSports.isEmpty ? sports : actualSports - ) - - case .failure: - return nil } + + return segments } private func convertToTrip(option: ItineraryOption, preferences: TripPreferences) -> Trip { + print("πŸ” convertToTrip: option.stops.count = \(option.stops.count)") + let tripStops = option.stops.enumerated().map { index, stop in TripStop( stopNumber: index + 1, @@ -429,6 +523,8 @@ final class SuggestedTripsGenerator { ) } + print("πŸ” convertToTrip: tripStops.count = \(tripStops.count)") + return Trip( name: generateTripName(from: tripStops), preferences: preferences, @@ -447,4 +543,253 @@ final class SuggestedTripsGenerator { } return cities.joined(separator: " - ") } + + // MARK: - Corridor Trip Building + + private enum Direction { + case east, west + } + + /// Builds a trip following a geographic corridor (moving consistently east or west) + private func buildCorridorTrip( + games: [(game: Game, lon: Double)], + stadiums: [UUID: Stadium], + direction: Direction, + calendar: Calendar + ) -> [Game] { + guard !games.isEmpty else { return [] } + + var selectedGames: [Game] = [] + var lastLongitude: Double? + var lastDate: Date? + + // Max driving distance per day (roughly 500 miles = ~8 hours at 60mph) + let maxDailyMiles = 500.0 + + for (game, longitude) in games { + let gameDay = calendar.startOfDay(for: game.dateTime) + + // Skip if same day as last game + if let last = lastDate, calendar.isDate(gameDay, inSameDayAs: last) { + continue + } + + // Check direction constraint + if let lastLon = lastLongitude { + let isCorrectDirection: Bool + switch direction { + case .west: isCorrectDirection = longitude < lastLon + 2 // Allow small eastward drift + case .east: isCorrectDirection = longitude > lastLon - 2 // Allow small westward drift + } + + if !isCorrectDirection { + continue + } + + // Check driving distance is reasonable + if let lastGame = selectedGames.last, + let lastStadium = stadiums[lastGame.stadiumId], + let currentStadium = stadiums[game.stadiumId] { + let daysBetween = calendar.dateComponents([.day], from: lastDate!, to: gameDay).day ?? 0 + let maxMiles = Double(max(1, daysBetween)) * maxDailyMiles + + let distance = haversineDistance( + lat1: lastStadium.coordinate.latitude, + lon1: lastStadium.coordinate.longitude, + lat2: currentStadium.coordinate.latitude, + lon2: currentStadium.coordinate.longitude + ) + + if distance > maxMiles { + continue // Too far to drive + } + } + } + + selectedGames.append(game) + lastLongitude = longitude + lastDate = gameDay + + if selectedGames.count >= 6 { break } + } + + return selectedGames + } + + /// Builds a coast-to-coast route: start region β†’ middle games β†’ end region + /// Returns games in calendar order that are drivable between each stop + /// INVARIANT: No two games on the same calendar day can be at different stadiums + /// PREFERS: Shorter trips (fewer days) over more games + private func buildCoastToCoastRoute( + startGames: [Game], + middleGames: [Game], + endGames: [Game], + stadiums: [UUID: Stadium], + calendar: Calendar + ) -> [Game] { + // Sort all games by date + let sortedStart = startGames.sorted { $0.dateTime < $1.dateTime } + let sortedMiddle = middleGames.sorted { $0.dateTime < $1.dateTime } + let sortedEnd = endGames.sorted { $0.dateTime < $1.dateTime } + + // Maximum driving per day (roughly 500 miles) + let maxDailyMiles = 500.0 + + // Collect all valid routes to pick the shortest + var validRoutes: [(games: [Game], days: Int, distance: Double)] = [] + + for startGame in sortedStart { + guard let startStadium = stadiums[startGame.stadiumId] else { continue } + + // Find ending games that are AFTER the start game with enough time to drive cross-country + // Cross-country is roughly 2500-3000 miles, so need at least 5-6 days + let minDaysForCrossCountry = 5 + let maxDaysForTrip = 14 // Cap trips at 2 weeks for reasonable length + + for endGame in sortedEnd { + guard let endStadium = stadiums[endGame.stadiumId] else { continue } + + let daysBetween = calendar.dateComponents([.day], from: startGame.dateTime, to: endGame.dateTime).day ?? 0 + + // Need enough time to drive cross-country but not TOO long + guard daysBetween >= minDaysForCrossCountry && daysBetween <= maxDaysForTrip else { continue } + + // Build route: start β†’ middle games β†’ end + var route: [Game] = [startGame] + var lastGame = startGame + var lastStadium = startStadium + var totalDistance: Double = 0 + + // Track all days with games and their stadiums for conflict detection + var gamesByDay: [Date: UUID] = [:] + gamesByDay[calendar.startOfDay(for: startGame.dateTime)] = startGame.stadiumId + + // Find middle games that fit between start and end (limit to 2-3 middle stops) + var middleStopsAdded = 0 + let maxMiddleStops = 3 + + for middleGame in sortedMiddle { + guard middleStopsAdded < maxMiddleStops else { break } + guard let middleStadium = stadiums[middleGame.stadiumId] else { continue } + + // Must be after last game + guard middleGame.dateTime > lastGame.dateTime else { continue } + + // Must be before end game + guard middleGame.dateTime < endGame.dateTime else { continue } + + // CRITICAL: Check ALL games in route for same-day conflict, not just last game + let middleGameDay = calendar.startOfDay(for: middleGame.dateTime) + if let existingStadiumId = gamesByDay[middleGameDay] { + if middleGame.stadiumId != existingStadiumId { + continue // Can't be in two different cities on the same day + } + } + + // Check if drivable from last stop + let daysFromLast = calendar.dateComponents([.day], from: lastGame.dateTime, to: middleGame.dateTime).day ?? 0 + let maxMilesFromLast = Double(max(1, daysFromLast)) * maxDailyMiles + let distanceFromLast = haversineDistance( + lat1: lastStadium.coordinate.latitude, + lon1: lastStadium.coordinate.longitude, + lat2: middleStadium.coordinate.latitude, + lon2: middleStadium.coordinate.longitude + ) + + guard distanceFromLast <= maxMilesFromLast else { continue } + + // Check if we can still reach the end from this middle game + let daysToEnd = calendar.dateComponents([.day], from: middleGame.dateTime, to: endGame.dateTime).day ?? 0 + let maxMilesToEnd = Double(max(1, daysToEnd)) * maxDailyMiles + let distanceToEnd = haversineDistance( + lat1: middleStadium.coordinate.latitude, + lon1: middleStadium.coordinate.longitude, + lat2: endStadium.coordinate.latitude, + lon2: endStadium.coordinate.longitude + ) + + guard distanceToEnd <= maxMilesToEnd else { continue } + + // This middle game works! + route.append(middleGame) + totalDistance += distanceFromLast + gamesByDay[middleGameDay] = middleGame.stadiumId + lastGame = middleGame + lastStadium = middleStadium + middleStopsAdded += 1 + } + + // CRITICAL: Check end game against ALL days in route + let endGameDay = calendar.startOfDay(for: endGame.dateTime) + if let existingStadiumId = gamesByDay[endGameDay] { + if endGame.stadiumId != existingStadiumId { + continue // Can't be in two different cities on the same day + } + } + + let daysToEnd = calendar.dateComponents([.day], from: lastGame.dateTime, to: endGame.dateTime).day ?? 0 + let maxMilesToEnd = Double(max(1, daysToEnd)) * maxDailyMiles + let distanceToEnd = haversineDistance( + lat1: lastStadium.coordinate.latitude, + lon1: lastStadium.coordinate.longitude, + lat2: endStadium.coordinate.latitude, + lon2: endStadium.coordinate.longitude + ) + + if distanceToEnd <= maxMilesToEnd { + route.append(endGame) + totalDistance += distanceToEnd + + // Final validation: ensure no same-day conflicts in entire route + if validateNoSameDayConflicts(route, calendar: calendar) && route.count >= 3 { + validRoutes.append((games: route, days: daysBetween, distance: totalDistance)) + } + } + } + } + + // Sort routes: prefer fewer days, then less distance + let sortedRoutes = validRoutes.sorted { a, b in + if a.days != b.days { + return a.days < b.days + } + return a.distance < b.distance + } + + // Return the shortest valid route + if let best = sortedRoutes.first { + print("βœ… C2C route: \(best.days) days, \(Int(best.distance)) miles, \(best.games.count) games") + return best.games + } + + return [] + } + + /// Validates that no two games in the route are on the same calendar day at different stadiums + private func validateNoSameDayConflicts(_ games: [Game], calendar: Calendar) -> Bool { + var gamesByDay: [Date: UUID] = [:] + for game in games { + let day = calendar.startOfDay(for: game.dateTime) + if let existingStadiumId = gamesByDay[day] { + if game.stadiumId != existingStadiumId { + print("⚠️ Same-day conflict: \(day) has games at different stadiums") + return false + } + } + gamesByDay[day] = game.stadiumId + } + return true + } + + /// Haversine distance in miles between two coordinates + private func haversineDistance(lat1: Double, lon1: Double, lat2: Double, lon2: Double) -> Double { + let R = 3959.0 // Earth radius in miles + let dLat = (lat2 - lat1) * .pi / 180 + let dLon = (lon2 - lon1) * .pi / 180 + let a = sin(dLat/2) * sin(dLat/2) + + cos(lat1 * .pi / 180) * cos(lat2 * .pi / 180) * + sin(dLon/2) * sin(dLon/2) + let c = 2 * atan2(sqrt(a), sqrt(1-a)) + return R * c + } } diff --git a/SportsTime/Core/Theme/AnimatedComponents.swift b/SportsTime/Core/Theme/AnimatedComponents.swift index 5482351..4f71bb1 100644 --- a/SportsTime/Core/Theme/AnimatedComponents.swift +++ b/SportsTime/Core/Theme/AnimatedComponents.swift @@ -206,27 +206,30 @@ struct RoutePreviewStrip: View { } var body: some View { - HStack(spacing: 4) { - ForEach(Array(cities.enumerated()), id: \.offset) { index, city in - if index > 0 { - // Connector line - Rectangle() - .fill(Theme.routeGold.opacity(0.5)) - .frame(width: 12, height: 2) - } + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 4) { + ForEach(Array(cities.enumerated()), id: \.offset) { index, city in + if index > 0 { + // Connector line + Rectangle() + .fill(Theme.routeGold.opacity(0.5)) + .frame(width: 12, height: 2) + } - // City dot with label - VStack(spacing: 4) { - Circle() - .fill(index == 0 || index == cities.count - 1 ? Theme.warmOrange : Theme.routeGold) - .frame(width: 8, height: 8) + // City dot with label + VStack(spacing: 4) { + Circle() + .fill(index == 0 || index == cities.count - 1 ? Theme.warmOrange : Theme.routeGold) + .frame(width: 8, height: 8) - Text(abbreviateCity(city)) - .font(.system(size: 10, weight: .medium)) - .foregroundStyle(Theme.textSecondary(colorScheme)) - .lineLimit(1) + Text(abbreviateCity(city)) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(Theme.textSecondary(colorScheme)) + .lineLimit(1) + } } } + .padding(.horizontal, Theme.Spacing.md) } } diff --git a/SportsTime/Features/Home/Views/HomeView.swift b/SportsTime/Features/Home/Views/HomeView.swift index 8eda66c..d2b44b4 100644 --- a/SportsTime/Features/Home/Views/HomeView.swift +++ b/SportsTime/Features/Home/Views/HomeView.swift @@ -120,7 +120,6 @@ struct HomeView: View { NavigationStack { TripDetailView(trip: suggestedTrip.trip, games: suggestedTrip.richGames) } - .interactiveDismissDisabled() } } diff --git a/SportsTime/Features/Home/Views/SuggestedTripCard.swift b/SportsTime/Features/Home/Views/SuggestedTripCard.swift index 96dc6e8..f66b0ae 100644 --- a/SportsTime/Features/Home/Views/SuggestedTripCard.swift +++ b/SportsTime/Features/Home/Views/SuggestedTripCard.swift @@ -79,33 +79,45 @@ struct SuggestedTripCard: View { private var routePreview: some View { let cities = suggestedTrip.trip.stops.map { $0.city } - let displayCities: [String] + let startCity = cities.first ?? "" + let endCity = cities.last ?? "" - if cities.count <= 3 { - displayCities = cities - } else { - displayCities = [cities.first ?? "", "...", cities.last ?? ""] - } + return VStack(alignment: .leading, spacing: Theme.Spacing.xs) { + // Start β†’ End display + HStack(spacing: 6) { + Text(startCity) + .font(.system(size: Theme.FontSize.caption, weight: .semibold)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + .lineLimit(1) - return VStack(alignment: .leading, spacing: 0) { - ForEach(Array(displayCities.enumerated()), id: \.offset) { index, city in - if index > 0 { - // Connector - HStack(spacing: 4) { - Text("|") - .font(.system(size: 10)) - Image(systemName: "chevron.down") - .font(.system(size: 8)) - } - .foregroundStyle(Theme.warmOrange.opacity(0.6)) - .padding(.leading, 4) - } + Image(systemName: "arrow.right") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(Theme.warmOrange) - Text(city) - .font(.system(size: Theme.FontSize.caption, weight: index == 0 ? .semibold : .regular)) - .foregroundStyle(index == 0 ? Theme.textPrimary(colorScheme) : Theme.textSecondary(colorScheme)) + Text(endCity) + .font(.system(size: Theme.FontSize.caption, weight: .semibold)) + .foregroundStyle(Theme.textPrimary(colorScheme)) .lineLimit(1) } + + // Scrollable stop dots + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 4) { + ForEach(0.. Bool { // Time must move forward - guard to.startTime > from.startTime else { return false } + guard to.startTime > from.startTime else { + return false + } // Same stadium = always feasible (no driving needed) if from.stadiumId == to.stadiumId { return true } @@ -224,6 +233,7 @@ enum GameDAGRouter { guard let fromStadium = stadiums[from.stadiumId], let toStadium = stadiums[to.stadiumId] else { // Missing stadium info - can't calculate distance, reject to be safe + print("⚠️ DAG: Stadium lookup failed - from:\(stadiums[from.stadiumId] != nil) to:\(stadiums[to.stadiumId] != nil)") return false } @@ -264,10 +274,14 @@ enum GameDAGRouter { } // Check if we have enough driving time - guard drivingHours <= maxDrivingHoursAvailable else { return false } + guard drivingHours <= maxDrivingHoursAvailable else { + return false + } // Also verify we can arrive before game starts (sanity check) - guard availableHours >= drivingHours else { return false } + guard availableHours >= drivingHours else { + return false + } return true } diff --git a/SportsTime/Planning/Engine/ScenarioAPlanner.swift b/SportsTime/Planning/Engine/ScenarioAPlanner.swift index 157779e..f43ffe3 100644 --- a/SportsTime/Planning/Engine/ScenarioAPlanner.swift +++ b/SportsTime/Planning/Engine/ScenarioAPlanner.swift @@ -97,6 +97,13 @@ final class ScenarioAPlanner: ScenarioPlanner { stopBuilder: buildStops ) + print("πŸ” ScenarioA: gamesInRange=\(gamesInRange.count), validRoutes=\(validRoutes.count)") + if let firstRoute = validRoutes.first { + print("πŸ” ScenarioA: First route has \(firstRoute.count) games") + let cities = firstRoute.compactMap { request.stadiums[$0.stadiumId]?.city } + print("πŸ” ScenarioA: Route cities: \(cities)") + } + if validRoutes.isEmpty { return .failure( PlanningFailure( @@ -127,8 +134,14 @@ final class ScenarioAPlanner: ScenarioPlanner { routesAttempted += 1 // Build stops for this route let stops = buildStops(from: routeGames, stadiums: request.stadiums) + + // Debug: show stops created from games + let stopCities = stops.map { "\($0.city)(\($0.games.count)g)" } + print("πŸ” ScenarioA: Route \(index) - \(routeGames.count) games β†’ \(stops.count) stops: \(stopCities)") + guard !stops.isEmpty else { routesFailed += 1 + print("⚠️ ScenarioA: Route \(index) - buildStops returned empty") continue } @@ -139,6 +152,7 @@ final class ScenarioAPlanner: ScenarioPlanner { ) else { // This route fails driving constraints, skip it routesFailed += 1 + print("⚠️ ScenarioA: Route \(index) - ItineraryBuilder.build failed") continue } @@ -178,12 +192,24 @@ final class ScenarioAPlanner: ScenarioPlanner { // Sort and rank based on leisure level let leisureLevel = request.preferences.leisureLevel + + // Debug: show all options before sorting + print("πŸ” ScenarioA: \(itineraryOptions.count) itinerary options before sorting:") + for (i, opt) in itineraryOptions.enumerated() { + print(" Option \(i): \(opt.stops.count) stops, \(opt.totalGames) games, \(String(format: "%.1f", opt.totalDrivingHours))h driving") + } + let rankedOptions = ItineraryOption.sortByLeisure( itineraryOptions, leisureLevel: leisureLevel, limit: request.preferences.maxTripOptions ) + print("πŸ” ScenarioA: Returning \(rankedOptions.count) options after sorting (limit=\(request.preferences.maxTripOptions))") + if let first = rankedOptions.first { + print("πŸ” ScenarioA: First option has \(first.stops.count) stops") + } + return .success(rankedOptions) }