diff --git a/SportsTime/Core/Services/SuggestedTripsGenerator.swift b/SportsTime/Core/Services/SuggestedTripsGenerator.swift index 8844b15..8860ef6 100644 --- a/SportsTime/Core/Services/SuggestedTripsGenerator.swift +++ b/SportsTime/Core/Services/SuggestedTripsGenerator.swift @@ -56,7 +56,16 @@ final class SuggestedTripsGenerator { var tripsByRegion: [(region: Region, trips: [SuggestedTrip])] { let grouped = Dictionary(grouping: suggestedTrips) { $0.region } return Region.allCases.compactMap { region in - guard let trips = grouped[region], !trips.isEmpty else { return nil } + guard var trips = grouped[region], !trips.isEmpty else { return nil } + + // For cross-country trips, sort by most stops (descending) and limit to top 2 + if region == .crossCountry { + trips = trips + .sorted { $0.trip.stops.count > $1.trip.stops.count } + .prefix(2) + .map { $0 } + } + return (region: region, trips: trips) } } diff --git a/SportsTime/Features/Home/Views/HomeView.swift b/SportsTime/Features/Home/Views/HomeView.swift index 2eaa383..6cd30b3 100644 --- a/SportsTime/Features/Home/Views/HomeView.swift +++ b/SportsTime/Features/Home/Views/HomeView.swift @@ -436,6 +436,11 @@ struct SavedTripsListView: View { let trips: [SavedTrip] @Environment(\.colorScheme) private var colorScheme + /// Trips sorted by most cities (stops) first + private var sortedTrips: [SavedTrip] { + trips.sorted { ($0.trip?.stops.count ?? 0) > ($1.trip?.stops.count ?? 0) } + } + var body: some View { ScrollView { if trips.isEmpty { @@ -460,7 +465,7 @@ struct SavedTripsListView: View { .frame(maxWidth: .infinity) } else { LazyVStack(spacing: Theme.Spacing.md) { - ForEach(Array(trips.enumerated()), id: \.element.id) { index, savedTrip in + ForEach(Array(sortedTrips.enumerated()), id: \.element.id) { index, savedTrip in if let trip = savedTrip.trip { NavigationLink { TripDetailView(trip: trip, games: savedTrip.games) @@ -476,6 +481,7 @@ struct SavedTripsListView: View { } } .themedBackground() + .navigationTitle("My Trips") } } diff --git a/SportsTime/Features/Progress/Views/ProgressMapView.swift b/SportsTime/Features/Progress/Views/ProgressMapView.swift index 8301511..037b834 100644 --- a/SportsTime/Features/Progress/Views/ProgressMapView.swift +++ b/SportsTime/Features/Progress/Views/ProgressMapView.swift @@ -15,31 +15,39 @@ struct ProgressMapView: View { let visitStatus: [UUID: StadiumVisitStatus] @Binding var selectedStadium: Stadium? - @State private var mapRegion = MKCoordinateRegion( - center: CLLocationCoordinate2D(latitude: 39.8283, longitude: -98.5795), // US center - span: MKCoordinateSpan(latitudeDelta: 50, longitudeDelta: 50) + // Fixed region for continental US - map is locked to this view + private let usRegion = MKCoordinateRegion( + center: CLLocationCoordinate2D(latitude: 39.8283, longitude: -98.5795), + span: MKCoordinateSpan(latitudeDelta: 50, longitudeDelta: 60) ) var body: some View { - Map(coordinateRegion: $mapRegion, annotationItems: stadiums) { stadium in - MapAnnotation(coordinate: CLLocationCoordinate2D( - latitude: stadium.latitude, - longitude: stadium.longitude - )) { - StadiumMapPin( - stadium: stadium, - isVisited: isVisited(stadium), - isSelected: selectedStadium?.id == stadium.id, - onTap: { - withAnimation(.spring(response: 0.3)) { - if selectedStadium?.id == stadium.id { - selectedStadium = nil - } else { - selectedStadium = stadium + // Use initialPosition with empty interactionModes to disable pan/zoom + // while keeping annotations tappable + Map(initialPosition: .region(usRegion), interactionModes: []) { + ForEach(stadiums) { stadium in + Annotation( + stadium.name, + coordinate: CLLocationCoordinate2D( + latitude: stadium.latitude, + longitude: stadium.longitude + ) + ) { + StadiumMapPin( + stadium: stadium, + isVisited: isVisited(stadium), + isSelected: selectedStadium?.id == stadium.id, + onTap: { + withAnimation(.spring(response: 0.3)) { + if selectedStadium?.id == stadium.id { + selectedStadium = nil + } else { + selectedStadium = stadium + } } } - } - ) + ) + } } } .mapStyle(.standard(elevation: .realistic)) diff --git a/SportsTime/Features/Schedule/Views/ScheduleListView.swift b/SportsTime/Features/Schedule/Views/ScheduleListView.swift index 2d76cac..8b8d140 100644 --- a/SportsTime/Features/Schedule/Views/ScheduleListView.swift +++ b/SportsTime/Features/Schedule/Views/ScheduleListView.swift @@ -209,14 +209,31 @@ struct GameRowView: View { let game: RichGame var showDate: Bool = false + private var isToday: Bool { + Calendar.current.isDateInToday(game.game.dateTime) + } + var body: some View { VStack(alignment: .leading, spacing: 8) { // Date (when grouped by sport) if showDate { - Text(formattedDate) - .font(.caption) - .fontWeight(.medium) - .foregroundStyle(.secondary) + HStack(spacing: 6) { + Text(formattedDate) + .font(.caption) + .fontWeight(.medium) + .foregroundStyle(.secondary) + + if isToday { + Text("TODAY") + .font(.caption2) + .fontWeight(.bold) + .foregroundStyle(.white) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.orange) + .clipShape(Capsule()) + } + } } // Teams @@ -247,6 +264,7 @@ struct GameRowView: View { .foregroundStyle(.secondary) } .padding(.vertical, 4) + .listRowBackground(isToday ? Color.orange.opacity(0.1) : nil) } private var formattedDate: String { diff --git a/SportsTime/Features/Trip/ViewModels/TripCreationViewModel.swift b/SportsTime/Features/Trip/ViewModels/TripCreationViewModel.swift index fc7caf9..e320ee9 100644 --- a/SportsTime/Features/Trip/ViewModels/TripCreationViewModel.swift +++ b/SportsTime/Features/Trip/ViewModels/TripCreationViewModel.swift @@ -36,7 +36,33 @@ final class TripCreationViewModel { // MARK: - Planning Mode - var planningMode: PlanningMode = .dateRange + var planningMode: PlanningMode = .dateRange { + didSet { + // Reset state when mode changes to ensure clean UI transitions + if oldValue != planningMode { + viewState = .editing + // Clear mode-specific selections + switch planningMode { + case .dateRange, .gameFirst: + // Clear locations for date-based modes + startLocationText = "" + endLocationText = "" + startLocation = nil + endLocation = nil + case .locations: + // Keep locations (user needs to enter them) + break + case .followTeam: + // Clear locations and must-see games for follow team mode + startLocationText = "" + endLocationText = "" + startLocation = nil + endLocation = nil + mustSeeGameIds.removeAll() + } + } + } + } // MARK: - Form Fields @@ -61,7 +87,9 @@ final class TripCreationViewModel { var startDate: Date = Date() { didSet { // Clear cached games when start date changes - if !Calendar.current.isDate(startDate, inSameDayAs: oldValue) { + // BUT: In gameFirst mode, games are the source of truth for dates, + // so don't clear them (fixes "date range required" error) + if !Calendar.current.isDate(startDate, inSameDayAs: oldValue) && planningMode != .gameFirst { availableGames = [] games = [] } @@ -70,7 +98,9 @@ final class TripCreationViewModel { var endDate: Date = Date().addingTimeInterval(86400 * 7) { didSet { // Clear cached games when end date changes - if !Calendar.current.isDate(endDate, inSameDayAs: oldValue) { + // BUT: In gameFirst mode, games are the source of truth for dates, + // so don't clear them (fixes "date range required" error) + if !Calendar.current.isDate(endDate, inSameDayAs: oldValue) && planningMode != .gameFirst { availableGames = [] games = [] } @@ -414,32 +444,8 @@ final class TripCreationViewModel { } func switchPlanningMode(_ mode: PlanningMode) { + // Just set the mode - didSet observer handles state reset planningMode = mode - // Clear mode-specific selections when switching - switch mode { - case .dateRange: - startLocationText = "" - endLocationText = "" - startLocation = nil - endLocation = nil - case .gameFirst: - // Keep games, clear locations - startLocationText = "" - endLocationText = "" - startLocation = nil - endLocation = nil - case .locations: - // Keep locations, optionally keep selected games - break - - case .followTeam: - // Clear non-follow-team selections - startLocationText = "" - endLocationText = "" - startLocation = nil - endLocation = nil - mustSeeGameIds.removeAll() - } } /// Load games for browsing in game-first mode diff --git a/SportsTime/Features/Trip/Views/TripCreationView.swift b/SportsTime/Features/Trip/Views/TripCreationView.swift index 8ecb201..4970519 100644 --- a/SportsTime/Features/Trip/Views/TripCreationView.swift +++ b/SportsTime/Features/Trip/Views/TripCreationView.swift @@ -38,6 +38,7 @@ struct TripCreationView: View { enum CityInputType { case mustStop case preferred + case homeLocation } var body: some View { @@ -121,6 +122,9 @@ struct TripCreationView: View { viewModel.addMustStopLocation(location) case .preferred: viewModel.addPreferredCity(location.name) + case .homeLocation: + viewModel.startLocationText = location.name + viewModel.startLocation = location } } } @@ -595,38 +599,50 @@ struct TripCreationView: View { ) if viewModel.useHomeLocation { - // Show home location input with suggestions - VStack(alignment: .leading, spacing: 0) { - ThemedTextField( - label: "Home Location", - placeholder: "Enter your city", - text: $viewModel.startLocationText, - icon: "house.fill" - ) - .onChange(of: viewModel.startLocationText) { _, newValue in - searchLocation(query: newValue, isStart: true) - } + // Show button to open location search sheet (same as must-stop) + Button { + cityInputType = .homeLocation + showCityInput = true + } label: { + HStack(spacing: Theme.Spacing.md) { + ZStack { + Circle() + .fill(Theme.warmOrange.opacity(0.15)) + .frame(width: 40, height: 40) + Image(systemName: "house.fill") + .foregroundStyle(Theme.warmOrange) + } - // Suggestions for home location - if !startLocationSuggestions.isEmpty { - locationSuggestionsList( - suggestions: startLocationSuggestions, - isLoading: isSearchingStart - ) { result in - viewModel.startLocationText = result.name - viewModel.startLocation = result.toLocationInput() - startLocationSuggestions = [] + VStack(alignment: .leading, spacing: 2) { + if let location = viewModel.startLocation { + Text(location.name) + .font(.body) + .foregroundStyle(Theme.textPrimary(colorScheme)) + if let address = location.address, !address.isEmpty { + Text(address) + .font(.caption) + .foregroundStyle(Theme.textSecondary(colorScheme)) + } + } else { + Text("Choose home location") + .font(.body) + .foregroundStyle(Theme.textPrimary(colorScheme)) + Text("Tap to search cities") + .font(.caption) + .foregroundStyle(Theme.textMuted(colorScheme)) + } } - } else if isSearchingStart { - HStack { - ThemedSpinnerCompact(size: 14) - Text("Searching...") - .font(.subheadline) - .foregroundStyle(Theme.textMuted(colorScheme)) - } - .padding(.top, Theme.Spacing.xs) + + Spacer() + + Image(systemName: "chevron.right") + .foregroundStyle(Theme.textMuted(colorScheme)) } + .padding(Theme.Spacing.md) + .background(Theme.cardBackgroundElevated(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) } + .buttonStyle(.plain) } else { Text("Trip will start at first game and end at last game (fly-in/fly-out)") .font(.caption) @@ -2099,6 +2115,14 @@ struct DateRangePicker: View { // Trip duration tripDurationBadge } + .onAppear { + // Initialize displayed month to show the start date's month + displayedMonth = calendar.startOfDay(for: startDate) + // If dates are already selected (endDate > startDate), show complete state + if endDate > startDate { + selectionState = .complete + } + } } private var selectedRangeSummary: some View { diff --git a/SportsTime/Features/Trip/Views/TripDetailView.swift b/SportsTime/Features/Trip/Views/TripDetailView.swift index e8c4f6d..2e72580 100644 --- a/SportsTime/Features/Trip/Views/TripDetailView.swift +++ b/SportsTime/Features/Trip/Views/TripDetailView.swift @@ -157,7 +157,7 @@ struct TripDetailView: View { private var heroMapSection: some View { ZStack(alignment: .bottom) { - Map(position: $mapCameraPosition) { + Map(position: $mapCameraPosition, interactionModes: []) { ForEach(stopCoordinates.indices, id: \.self) { index in let stop = stopCoordinates[index] Annotation(stop.name, coordinate: stop.coordinate) { diff --git a/SportsTime/Planning/Engine/ScenarioAPlanner.swift b/SportsTime/Planning/Engine/ScenarioAPlanner.swift index d9768fb..350e720 100644 --- a/SportsTime/Planning/Engine/ScenarioAPlanner.swift +++ b/SportsTime/Planning/Engine/ScenarioAPlanner.swift @@ -15,7 +15,7 @@ import CoreLocation /// /// Input: /// - date_range: Required. The trip dates (e.g., Jan 5-15) -/// - must_stop: Optional. A location they must visit (not yet implemented) +/// - must_stop: Optional. A location they must visit (filters to home games in that city) /// /// Output: /// - Success: Ranked list of itinerary options @@ -90,6 +90,37 @@ final class ScenarioAPlanner: ScenarioPlanner { ) } + // ────────────────────────────────────────────────────────────────── + // Step 2b: Filter by must-stop locations (if any) + // ────────────────────────────────────────────────────────────────── + // If user specified a must-stop city, filter to HOME games in that city. + // A "home game" means the stadium is in the must-stop city. + var filteredGames = gamesInRange + if let mustStop = request.mustStopLocation { + let mustStopCity = mustStop.name.lowercased() + filteredGames = gamesInRange.filter { game in + guard let stadium = request.stadiums[game.stadiumId] else { return false } + let stadiumCity = stadium.city.lowercased() + // Match if either contains the other (handles "Chicago" vs "Chicago, IL") + return stadiumCity.contains(mustStopCity) || mustStopCity.contains(stadiumCity) + } + + if filteredGames.isEmpty { + return .failure( + PlanningFailure( + reason: .noGamesInRange, + violations: [ + ConstraintViolation( + type: .mustStop, + description: "No home games found in \(mustStop.name) during selected dates", + severity: .error + ) + ] + ) + ) + } + } + // ────────────────────────────────────────────────────────────────── // Step 3: Find ALL geographically sensible route variations // ────────────────────────────────────────────────────────────────── @@ -112,7 +143,7 @@ final class ScenarioAPlanner: ScenarioPlanner { // Global beam search (finds cross-region routes) let globalRoutes = GameDAGRouter.findAllSensibleRoutes( - from: gamesInRange, + from: filteredGames, stadiums: request.stadiums, allowRepeatCities: request.preferences.allowRepeatCities, stopBuilder: buildStops @@ -121,7 +152,7 @@ final class ScenarioAPlanner: ScenarioPlanner { // Per-region beam search (ensures good regional options) let regionalRoutes = findRoutesPerRegion( - games: gamesInRange, + games: filteredGames, stadiums: request.stadiums, allowRepeatCities: request.preferences.allowRepeatCities ) @@ -130,7 +161,7 @@ final class ScenarioAPlanner: ScenarioPlanner { // Deduplicate routes (same game IDs) validRoutes = deduplicateRoutes(validRoutes) - print("🔍 ScenarioA: gamesInRange=\(gamesInRange.count), validRoutes=\(validRoutes.count)") + print("🔍 ScenarioA: filteredGames=\(filteredGames.count), validRoutes=\(validRoutes.count)") if let firstRoute = validRoutes.first { print("🔍 ScenarioA: First route has \(firstRoute.count) games") let cities = firstRoute.compactMap { request.stadiums[$0.stadiumId]?.city } diff --git a/SportsTimeTests/Planning/ScenarioAPlannerTests.swift b/SportsTimeTests/Planning/ScenarioAPlannerTests.swift index 3c5706a..cc5be25 100644 --- a/SportsTimeTests/Planning/ScenarioAPlannerTests.swift +++ b/SportsTimeTests/Planning/ScenarioAPlannerTests.swift @@ -78,7 +78,8 @@ struct ScenarioAPlannerTests { teams: [UUID: Team] = [:], allowRepeatCities: Bool = true, numberOfDrivers: Int = 1, - maxDrivingHoursPerDriver: Double = 8.0 + maxDrivingHoursPerDriver: Double = 8.0, + mustStopLocations: [LocationInput] = [] ) -> PlanningRequest { let preferences = TripPreferences( planningMode: .dateRange, @@ -86,6 +87,7 @@ struct ScenarioAPlannerTests { startDate: startDate, endDate: endDate, leisureLevel: .moderate, + mustStopLocations: mustStopLocations, numberOfDrivers: numberOfDrivers, maxDrivingHoursPerDriver: maxDrivingHoursPerDriver, allowRepeatCities: allowRepeatCities @@ -465,4 +467,230 @@ struct ScenarioAPlannerTests { "Should have options with relaxed driver constraints") } } + + // MARK: - 4D: Must-Stop Filtering (Issues #4 & #8) + + @Test("4.10 - Must-stop filters to games in that city") + func test_planByDates_MustStop_FiltersToGamesInCity() { + // Setup: Games in Chicago, Milwaukee, Detroit + // Must-stop = Chicago → should only return Chicago games + let chicagoId = UUID() + let milwaukeeId = UUID() + let detroitId = UUID() + + let chicago = makeStadium(id: chicagoId, city: "Chicago", lat: 41.8781, lon: -87.6298) + let milwaukee = makeStadium(id: milwaukeeId, city: "Milwaukee", lat: 43.0389, lon: -87.9065) + let detroit = makeStadium(id: detroitId, city: "Detroit", lat: 42.3314, lon: -83.0458) + + let stadiums = [chicagoId: chicago, milwaukeeId: milwaukee, detroitId: detroit] + + let chicagoGame = makeGame(stadiumId: chicagoId, dateTime: makeDate(day: 5, hour: 19)) + let milwaukeeGame = makeGame(stadiumId: milwaukeeId, dateTime: makeDate(day: 7, hour: 19)) + let detroitGame = makeGame(stadiumId: detroitId, dateTime: makeDate(day: 9, hour: 19)) + + let mustStop = LocationInput(name: "Chicago") + + let request = makePlanningRequest( + startDate: makeDate(day: 4, hour: 0), + endDate: makeDate(day: 10, hour: 23), + games: [chicagoGame, milwaukeeGame, detroitGame], + stadiums: stadiums, + mustStopLocations: [mustStop] + ) + + // Execute + let result = planner.plan(request: request) + + // Verify: Should succeed and ONLY include Chicago games + #expect(result.isSuccess, "Should succeed with must-stop in Chicago") + #expect(!result.options.isEmpty, "Should return at least one option") + + // All games in result should be in Chicago only + for option in result.options { + let allGameIds = Set(option.stops.flatMap { $0.games }) + #expect(allGameIds.contains(chicagoGame.id), "Should include Chicago game") + #expect(!allGameIds.contains(milwaukeeGame.id), "Should NOT include Milwaukee game") + #expect(!allGameIds.contains(detroitGame.id), "Should NOT include Detroit game") + } + } + + @Test("4.11 - Must-stop with no matching games returns failure") + func test_planByDates_MustStop_NoMatchingGames_ReturnsFailure() { + // Setup: Games only in Milwaukee and Detroit + // Must-stop = Chicago → no games there, should fail + let milwaukeeId = UUID() + let detroitId = UUID() + + let milwaukee = makeStadium(id: milwaukeeId, city: "Milwaukee", lat: 43.0389, lon: -87.9065) + let detroit = makeStadium(id: detroitId, city: "Detroit", lat: 42.3314, lon: -83.0458) + + let stadiums = [milwaukeeId: milwaukee, detroitId: detroit] + + let milwaukeeGame = makeGame(stadiumId: milwaukeeId, dateTime: makeDate(day: 7, hour: 19)) + let detroitGame = makeGame(stadiumId: detroitId, dateTime: makeDate(day: 9, hour: 19)) + + let mustStop = LocationInput(name: "Chicago") + + let request = makePlanningRequest( + startDate: makeDate(day: 4, hour: 0), + endDate: makeDate(day: 10, hour: 23), + games: [milwaukeeGame, detroitGame], + stadiums: stadiums, + mustStopLocations: [mustStop] + ) + + // Execute + let result = planner.plan(request: request) + + // Verify: Should fail with noGamesInRange (no home games in Chicago) + #expect(!result.isSuccess, "Should fail when no games in must-stop city") + #expect(result.failure?.reason == .noGamesInRange, + "Should return noGamesInRange failure") + } + + @Test("4.12 - Must-stop only returns HOME games (Issue #8)") + func test_planByDates_MustStop_OnlyReturnsHomeGames() { + // Setup: Cubs home game in Chicago + Cubs away game in Milwaukee (playing at Milwaukee) + // Must-stop = Chicago → should ONLY return the Chicago home game + // This tests Issue #8: "Must stop needs to be home team" + let chicagoStadiumId = UUID() + let milwaukeeStadiumId = UUID() + let cubsTeamId = UUID() + let brewersTeamId = UUID() + + let wrigleyField = makeStadium(id: chicagoStadiumId, city: "Chicago", lat: 41.9484, lon: -87.6553) + let millerPark = makeStadium(id: milwaukeeStadiumId, city: "Milwaukee", lat: 43.0280, lon: -87.9712) + + let stadiums = [chicagoStadiumId: wrigleyField, milwaukeeStadiumId: millerPark] + + // Cubs HOME game at Wrigley (Chicago) + let cubsHomeGame = makeGame( + stadiumId: chicagoStadiumId, + homeTeamId: cubsTeamId, + awayTeamId: brewersTeamId, + dateTime: makeDate(day: 5, hour: 19) + ) + + // Cubs AWAY game at Miller Park (Milwaukee) - Cubs are playing but NOT at home + let cubsAwayGame = makeGame( + stadiumId: milwaukeeStadiumId, + homeTeamId: brewersTeamId, + awayTeamId: cubsTeamId, + dateTime: makeDate(day: 7, hour: 19) + ) + + let mustStop = LocationInput(name: "Chicago") + + let request = makePlanningRequest( + startDate: makeDate(day: 4, hour: 0), + endDate: makeDate(day: 10, hour: 23), + games: [cubsHomeGame, cubsAwayGame], + stadiums: stadiums, + mustStopLocations: [mustStop] + ) + + // Execute + let result = planner.plan(request: request) + + // Verify: Should succeed with ONLY the Chicago home game + #expect(result.isSuccess, "Should succeed with Chicago home game") + #expect(!result.options.isEmpty, "Should return at least one option") + + // The away game in Milwaukee should NOT be included even though Cubs are playing + for option in result.options { + let allGameIds = Set(option.stops.flatMap { $0.games }) + #expect(allGameIds.contains(cubsHomeGame.id), "Should include Cubs HOME game in Chicago") + #expect(!allGameIds.contains(cubsAwayGame.id), "Should NOT include Cubs AWAY game in Milwaukee") + } + } + + @Test("4.13 - Must-stop with partial city name match works") + func test_planByDates_MustStop_PartialCityMatch_Works() { + // Setup: User types "Chicago" but stadium city is "Chicago, IL" + // Should still match via contains + let chicagoId = UUID() + let chicago = makeStadium(id: chicagoId, city: "Chicago", lat: 41.8781, lon: -87.6298) + let stadiums = [chicagoId: chicago] + + let game = makeGame(stadiumId: chicagoId, dateTime: makeDate(day: 5, hour: 19)) + + // User might type partial name + let mustStop = LocationInput(name: "Chicago, IL") + + let request = makePlanningRequest( + startDate: makeDate(day: 4, hour: 0), + endDate: makeDate(day: 10, hour: 23), + games: [game], + stadiums: stadiums, + mustStopLocations: [mustStop] + ) + + // Execute + let result = planner.plan(request: request) + + // Verify: Should still find Chicago games with partial match + #expect(result.isSuccess, "Should succeed with partial city name match") + #expect(!result.options.isEmpty, "Should return options") + } + + @Test("4.14 - Must-stop case insensitive") + func test_planByDates_MustStop_CaseInsensitive() { + // Setup: Must-stop = "CHICAGO" (uppercase) should match "Chicago" + let chicagoId = UUID() + let chicago = makeStadium(id: chicagoId, city: "Chicago", lat: 41.8781, lon: -87.6298) + let stadiums = [chicagoId: chicago] + + let game = makeGame(stadiumId: chicagoId, dateTime: makeDate(day: 5, hour: 19)) + + let mustStop = LocationInput(name: "CHICAGO") + + let request = makePlanningRequest( + startDate: makeDate(day: 4, hour: 0), + endDate: makeDate(day: 10, hour: 23), + games: [game], + stadiums: stadiums, + mustStopLocations: [mustStop] + ) + + // Execute + let result = planner.plan(request: request) + + // Verify: Case insensitive match + #expect(result.isSuccess, "Should succeed with case-insensitive match") + } + + @Test("4.15 - Multiple games in must-stop city all included") + func test_planByDates_MustStop_MultipleGamesInCity_AllIncluded() { + // Setup: Multiple games in Chicago on different days + let chicagoId = UUID() + let chicago = makeStadium(id: chicagoId, city: "Chicago", lat: 41.8781, lon: -87.6298) + let stadiums = [chicagoId: chicago] + + let game1 = makeGame(stadiumId: chicagoId, dateTime: makeDate(day: 5, hour: 19)) + let game2 = makeGame(stadiumId: chicagoId, dateTime: makeDate(day: 7, hour: 13)) + let game3 = makeGame(stadiumId: chicagoId, dateTime: makeDate(day: 9, hour: 19)) + + let mustStop = LocationInput(name: "Chicago") + + let request = makePlanningRequest( + startDate: makeDate(day: 4, hour: 0), + endDate: makeDate(day: 10, hour: 23), + games: [game1, game2, game3], + stadiums: stadiums, + mustStopLocations: [mustStop] + ) + + // Execute + let result = planner.plan(request: request) + + // Verify: All Chicago games should be included + #expect(result.isSuccess, "Should succeed with multiple games in must-stop city") + + if let option = result.options.first { + let allGameIds = Set(option.stops.flatMap { $0.games }) + #expect(allGameIds.contains(game1.id), "Should include first Chicago game") + #expect(allGameIds.contains(game2.id), "Should include second Chicago game") + #expect(allGameIds.contains(game3.id), "Should include third Chicago game") + } + } }