feat: add Follow Team Mode (Scenario D) for road trip planning
Adds a new planning mode that lets users follow a team's schedule (home + away games) and builds multi-city routes accordingly. Key changes: - New ScenarioDPlanner with team filtering and route generation - Team picker UI with sport grouping and search - Fix TravelEstimator 5-day limit (was 2-day) for cross-country routes - Fix DateInterval end boundary to include games on last day - Comprehensive test suite covering edge cases: - Multi-city routes with adequate/insufficient time - Optimal game selection per city for feasibility - 5-day driving segment limits - Multiple driver scenarios Enables trips like Houston → Chicago → Anaheim following the Astros. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ enum PlanningMode: String, Codable, CaseIterable, Identifiable {
|
||||
case dateRange // Start date + end date, find games in range
|
||||
case gameFirst // Pick games first, trip around those games
|
||||
case locations // Start/end locations, optional games along route
|
||||
case followTeam // Follow one team's schedule (home + away)
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
@@ -20,6 +21,7 @@ enum PlanningMode: String, Codable, CaseIterable, Identifiable {
|
||||
case .dateRange: return "By Dates"
|
||||
case .gameFirst: return "By Games"
|
||||
case .locations: return "By Route"
|
||||
case .followTeam: return "Follow Team"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +30,7 @@ enum PlanningMode: String, Codable, CaseIterable, Identifiable {
|
||||
case .dateRange: return "Shows a curated sample of possible routes — use filters to find your ideal trip"
|
||||
case .gameFirst: return "Build trip around specific games"
|
||||
case .locations: return "Plan route between locations"
|
||||
case .followTeam: return "Follow your team on the road"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +39,7 @@ enum PlanningMode: String, Codable, CaseIterable, Identifiable {
|
||||
case .dateRange: return "calendar"
|
||||
case .gameFirst: return "sportscourt"
|
||||
case .locations: return "map"
|
||||
case .followTeam: return "person.3.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -230,6 +234,12 @@ struct TripPreferences: Codable, Hashable {
|
||||
var allowRepeatCities: Bool
|
||||
var selectedRegions: Set<Region>
|
||||
|
||||
/// Team to follow (for Follow Team mode)
|
||||
var followTeamId: UUID?
|
||||
|
||||
/// Whether to start/end from a home location (vs fly-in/fly-out)
|
||||
var useHomeLocation: Bool
|
||||
|
||||
init(
|
||||
planningMode: PlanningMode = .dateRange,
|
||||
startLocation: LocationInput? = nil,
|
||||
@@ -250,7 +260,9 @@ struct TripPreferences: Codable, Hashable {
|
||||
numberOfDrivers: Int = 1,
|
||||
maxDrivingHoursPerDriver: Double? = nil,
|
||||
allowRepeatCities: Bool = true,
|
||||
selectedRegions: Set<Region> = [.east, .central, .west]
|
||||
selectedRegions: Set<Region> = [.east, .central, .west],
|
||||
followTeamId: UUID? = nil,
|
||||
useHomeLocation: Bool = true
|
||||
) {
|
||||
self.planningMode = planningMode
|
||||
self.startLocation = startLocation
|
||||
@@ -272,6 +284,8 @@ struct TripPreferences: Codable, Hashable {
|
||||
self.maxDrivingHoursPerDriver = maxDrivingHoursPerDriver
|
||||
self.allowRepeatCities = allowRepeatCities
|
||||
self.selectedRegions = selectedRegions
|
||||
self.followTeamId = followTeamId
|
||||
self.useHomeLocation = useHomeLocation
|
||||
}
|
||||
|
||||
var totalDriverHoursPerDay: Double {
|
||||
|
||||
@@ -103,6 +103,10 @@ final class TripCreationViewModel {
|
||||
var allowRepeatCities: Bool = true
|
||||
var selectedRegions: Set<Region> = [.east, .central, .west]
|
||||
|
||||
// Follow Team Mode
|
||||
var followTeamId: UUID?
|
||||
var useHomeLocation: Bool = true
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
private let planningEngine = TripPlanningEngine()
|
||||
@@ -133,6 +137,14 @@ final class TripCreationViewModel {
|
||||
return !startLocationText.isEmpty &&
|
||||
!endLocationText.isEmpty &&
|
||||
!selectedSports.isEmpty
|
||||
|
||||
case .followTeam:
|
||||
// Need: team selected + valid date range
|
||||
guard followTeamId != nil else { return false }
|
||||
guard endDate > startDate else { return false }
|
||||
// If using home location, need a valid start location
|
||||
if useHomeLocation && startLocationText.isEmpty { return false }
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,6 +160,11 @@ final class TripCreationViewModel {
|
||||
if startLocationText.isEmpty { return "Enter a starting location" }
|
||||
if endLocationText.isEmpty { return "Enter an ending location" }
|
||||
if selectedSports.isEmpty { return "Select at least one sport" }
|
||||
|
||||
case .followTeam:
|
||||
if followTeamId == nil { return "Select a team to follow" }
|
||||
if endDate <= startDate { return "End date must be after start date" }
|
||||
if useHomeLocation && startLocationText.isEmpty { return "Enter your home location" }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -174,6 +191,25 @@ final class TripCreationViewModel {
|
||||
return (earliest, latest)
|
||||
}
|
||||
|
||||
/// Teams grouped by sport for Follow Team picker
|
||||
var teamsBySport: [Sport: [Team]] {
|
||||
var grouped: [Sport: [Team]] = [:]
|
||||
for team in dataProvider.teams {
|
||||
grouped[team.sport, default: []].append(team)
|
||||
}
|
||||
// Sort teams alphabetically within each sport
|
||||
for sport in grouped.keys {
|
||||
grouped[sport]?.sort { $0.name < $1.name }
|
||||
}
|
||||
return grouped
|
||||
}
|
||||
|
||||
/// The currently followed team (for display)
|
||||
var followedTeam: Team? {
|
||||
guard let teamId = followTeamId else { return nil }
|
||||
return dataProvider.teams.first { $0.id == teamId }
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
func loadScheduleData() async {
|
||||
@@ -279,6 +315,19 @@ final class TripCreationViewModel {
|
||||
viewState = .error("Could not resolve start or end location")
|
||||
return
|
||||
}
|
||||
|
||||
case .followTeam:
|
||||
// Use provided date range
|
||||
effectiveStartDate = startDate
|
||||
effectiveEndDate = endDate
|
||||
|
||||
// If using home location, resolve it
|
||||
if useHomeLocation && !startLocationText.isEmpty {
|
||||
await resolveLocations()
|
||||
resolvedStartLocation = startLocation
|
||||
resolvedEndLocation = startLocation // Round trip - same start/end
|
||||
}
|
||||
// Otherwise, planner will use first/last game locations (fly-in/fly-out)
|
||||
}
|
||||
|
||||
// Ensure we have games data
|
||||
@@ -307,7 +356,9 @@ final class TripCreationViewModel {
|
||||
numberOfDrivers: numberOfDrivers,
|
||||
maxDrivingHoursPerDriver: maxDrivingHoursPerDriver,
|
||||
allowRepeatCities: allowRepeatCities,
|
||||
selectedRegions: selectedRegions
|
||||
selectedRegions: selectedRegions,
|
||||
followTeamId: followTeamId,
|
||||
useHomeLocation: useHomeLocation
|
||||
)
|
||||
|
||||
// Build planning request
|
||||
@@ -380,6 +431,14 @@ final class TripCreationViewModel {
|
||||
case .locations:
|
||||
// Keep locations, optionally keep selected games
|
||||
break
|
||||
|
||||
case .followTeam:
|
||||
// Clear non-follow-team selections
|
||||
startLocationText = ""
|
||||
endLocationText = ""
|
||||
startLocation = nil
|
||||
endLocation = nil
|
||||
mustSeeGameIds.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -73,6 +73,12 @@ struct TripCreationView: View {
|
||||
sportsSection
|
||||
datesSection
|
||||
gamesSection
|
||||
|
||||
case .followTeam:
|
||||
// Team picker + Dates + Home location toggle
|
||||
teamPickerSection
|
||||
datesSection
|
||||
homeLocationSection
|
||||
}
|
||||
|
||||
// Common sections
|
||||
@@ -516,6 +522,113 @@ struct TripCreationView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Follow Team Mode
|
||||
|
||||
@State private var showTeamPicker = false
|
||||
|
||||
private var teamPickerSection: some View {
|
||||
ThemedSection(title: "Select Team") {
|
||||
Button {
|
||||
showTeamPicker = true
|
||||
} label: {
|
||||
HStack(spacing: Theme.Spacing.md) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Theme.warmOrange.opacity(0.15))
|
||||
.frame(width: 40, height: 40)
|
||||
Image(systemName: "person.3.fill")
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
if let team = viewModel.followedTeam {
|
||||
Text(team.name)
|
||||
.font(.body)
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
Text(team.sport.displayName)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
} else {
|
||||
Text("Choose a team")
|
||||
.font(.body)
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
Text("Pick the team to follow")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
.sheet(isPresented: $showTeamPicker) {
|
||||
TeamPickerSheet(
|
||||
selectedTeamId: $viewModel.followTeamId,
|
||||
teamsBySport: viewModel.teamsBySport
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var homeLocationSection: some View {
|
||||
ThemedSection(title: "Trip Start/End") {
|
||||
VStack(spacing: Theme.Spacing.md) {
|
||||
ThemedToggle(
|
||||
label: "Start and end from home",
|
||||
isOn: $viewModel.useHomeLocation,
|
||||
icon: "house.fill"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// Suggestions for home location
|
||||
if !startLocationSuggestions.isEmpty {
|
||||
locationSuggestionsList(
|
||||
suggestions: startLocationSuggestions,
|
||||
isLoading: isSearchingStart
|
||||
) { result in
|
||||
viewModel.startLocationText = result.name
|
||||
viewModel.startLocation = result.toLocationInput()
|
||||
startLocationSuggestions = []
|
||||
}
|
||||
} else if isSearchingStart {
|
||||
HStack {
|
||||
ThemedSpinnerCompact(size: 14)
|
||||
Text("Searching...")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
.padding(.top, Theme.Spacing.xs)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text("Trip will start at first game and end at last game (fly-in/fly-out)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
.padding(.leading, 32)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var gamesSection: some View {
|
||||
ThemedSection(title: "Must-See Games") {
|
||||
Button {
|
||||
@@ -2205,6 +2318,114 @@ struct DayCell: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Team Picker Sheet
|
||||
|
||||
struct TeamPickerSheet: View {
|
||||
@Binding var selectedTeamId: UUID?
|
||||
let teamsBySport: [Sport: [Team]]
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
@State private var searchText = ""
|
||||
|
||||
private var sortedSports: [Sport] {
|
||||
Sport.allCases.filter { teamsBySport[$0] != nil && !teamsBySport[$0]!.isEmpty }
|
||||
}
|
||||
|
||||
private var filteredTeamsBySport: [Sport: [Team]] {
|
||||
guard !searchText.isEmpty else { return teamsBySport }
|
||||
|
||||
var filtered: [Sport: [Team]] = [:]
|
||||
for (sport, teams) in teamsBySport {
|
||||
let matchingTeams = teams.filter {
|
||||
$0.name.localizedCaseInsensitiveContains(searchText) ||
|
||||
$0.city.localizedCaseInsensitiveContains(searchText) ||
|
||||
$0.abbreviation.localizedCaseInsensitiveContains(searchText)
|
||||
}
|
||||
if !matchingTeams.isEmpty {
|
||||
filtered[sport] = matchingTeams
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
ForEach(sortedSports, id: \.self) { sport in
|
||||
if let teams = filteredTeamsBySport[sport], !teams.isEmpty {
|
||||
Section(sport.displayName) {
|
||||
ForEach(teams) { team in
|
||||
TeamRow(
|
||||
team: team,
|
||||
isSelected: selectedTeamId == team.id,
|
||||
colorScheme: colorScheme
|
||||
) {
|
||||
selectedTeamId = team.id
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.searchable(text: $searchText, prompt: "Search teams")
|
||||
.navigationTitle("Select Team")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TeamRow: View {
|
||||
let team: Team
|
||||
let isSelected: Bool
|
||||
let colorScheme: ColorScheme
|
||||
let onTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
HStack(spacing: Theme.Spacing.md) {
|
||||
// Team color indicator
|
||||
if let colorHex = team.primaryColor {
|
||||
Circle()
|
||||
.fill(Color(hex: colorHex))
|
||||
.frame(width: 12, height: 12)
|
||||
} else {
|
||||
Circle()
|
||||
.fill(Theme.warmOrange)
|
||||
.frame(width: 12, height: 12)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(team.name)
|
||||
.font(.body)
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
Text(team.city)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if isSelected {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
TripCreationView(viewModel: TripCreationViewModel())
|
||||
}
|
||||
|
||||
@@ -546,7 +546,15 @@ enum GameDAGRouter {
|
||||
? max(0, availableHours)
|
||||
: Double(daysBetween) * constraints.maxDailyDrivingHours
|
||||
|
||||
return drivingHours <= maxDrivingHoursAvailable && drivingHours <= availableHours
|
||||
let feasible = drivingHours <= maxDrivingHoursAvailable && drivingHours <= availableHours
|
||||
|
||||
// Debug output for rejected transitions
|
||||
if !feasible && drivingHours > 10 {
|
||||
print("🔍 DAG canTransition REJECTED: \(fromStadium.city) → \(toStadium.city)")
|
||||
print("🔍 drivingHours=\(String(format: "%.1f", drivingHours)), daysBetween=\(daysBetween), maxAvailable=\(String(format: "%.1f", maxDrivingHoursAvailable)), availableHours=\(String(format: "%.1f", availableHours))")
|
||||
}
|
||||
|
||||
return feasible
|
||||
}
|
||||
|
||||
// MARK: - Distance Estimation
|
||||
|
||||
405
SportsTime/Planning/Engine/ScenarioDPlanner.swift
Normal file
405
SportsTime/Planning/Engine/ScenarioDPlanner.swift
Normal file
@@ -0,0 +1,405 @@
|
||||
//
|
||||
// ScenarioDPlanner.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Scenario D: Follow Team planning.
|
||||
// User selects a team, we find all their games (home and away) and build routes.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
|
||||
/// Scenario D: Follow Team planning
|
||||
///
|
||||
/// This scenario builds trips around a specific team's schedule.
|
||||
///
|
||||
/// Input:
|
||||
/// - followTeamId: Required. The team to follow.
|
||||
/// - date_range: Required. The trip dates.
|
||||
/// - selectedRegions: Optional. Filter to specific regions.
|
||||
/// - useHomeLocation: Whether to start/end from user's home.
|
||||
/// - startLocation: Required if useHomeLocation is true.
|
||||
///
|
||||
/// Output:
|
||||
/// - Success: Ranked list of itinerary options
|
||||
/// - Failure: Explicit error with reason (no team, no games, etc.)
|
||||
///
|
||||
/// Example:
|
||||
/// User follows Yankees, Jan 5-15, 2026
|
||||
/// We find: @Red Sox (Jan 5), @Blue Jays (Jan 8), vs Orioles (Jan 12)
|
||||
/// Output: Route visiting Boston → Toronto → New York
|
||||
///
|
||||
final class ScenarioDPlanner: ScenarioPlanner {
|
||||
|
||||
// MARK: - ScenarioPlanner Protocol
|
||||
|
||||
/// Main entry point for Scenario D planning.
|
||||
///
|
||||
/// Flow:
|
||||
/// 1. Validate inputs (team must be selected)
|
||||
/// 2. Filter games to team's schedule (home and away)
|
||||
/// 3. Apply region and date filters
|
||||
/// 4. Apply repeat city constraints
|
||||
/// 5. Build routes and calculate travel
|
||||
/// 6. Return ranked itineraries
|
||||
///
|
||||
/// Failure cases:
|
||||
/// - No team selected → .missingTeamSelection
|
||||
/// - No date range → .missingDateRange
|
||||
/// - No games found → .noGamesInRange
|
||||
/// - Can't build valid route → .constraintsUnsatisfiable
|
||||
///
|
||||
func plan(request: PlanningRequest) -> ItineraryResult {
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Step 1: Validate team selection
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
guard let teamId = request.preferences.followTeamId else {
|
||||
return .failure(
|
||||
PlanningFailure(
|
||||
reason: .missingTeamSelection,
|
||||
violations: []
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Step 2: Validate date range exists
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
guard let dateRange = request.dateRange else {
|
||||
return .failure(
|
||||
PlanningFailure(
|
||||
reason: .missingDateRange,
|
||||
violations: []
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Step 3: Filter games to team's schedule (home and away)
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
let teamGames = filterToTeam(request.allGames, teamId: teamId)
|
||||
|
||||
print("🔍 ScenarioD Step 3: allGames=\(request.allGames.count), teamGames=\(teamGames.count)")
|
||||
print("🔍 ScenarioD: Looking for teamId=\(teamId)")
|
||||
for game in teamGames.prefix(20) {
|
||||
let stadium = request.stadiums[game.stadiumId]
|
||||
let isHome = game.homeTeamId == teamId
|
||||
print("🔍 Game: \(stadium?.city ?? "?") on \(game.gameDate) (\(isHome ? "HOME" : "AWAY"))")
|
||||
}
|
||||
|
||||
if teamGames.isEmpty {
|
||||
return .failure(
|
||||
PlanningFailure(
|
||||
reason: .noGamesInRange,
|
||||
violations: [
|
||||
ConstraintViolation(
|
||||
type: .selectedGames,
|
||||
description: "No games found for selected team",
|
||||
severity: .error
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Step 4: Apply date range and region filters
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
let selectedRegions = request.preferences.selectedRegions
|
||||
print("🔍 ScenarioD Step 4: dateRange=\(dateRange.start) to \(dateRange.end), selectedRegions=\(selectedRegions)")
|
||||
|
||||
let filteredGames = teamGames
|
||||
.filter { game in
|
||||
// Must be in date range
|
||||
let inDateRange = dateRange.contains(game.startTime)
|
||||
if !inDateRange {
|
||||
let stadium = request.stadiums[game.stadiumId]
|
||||
print("🔍 FILTERED OUT (date): \(stadium?.city ?? "?") on \(game.gameDate) - startTime=\(game.startTime)")
|
||||
return false
|
||||
}
|
||||
|
||||
// Must be in selected region (if regions specified)
|
||||
if !selectedRegions.isEmpty {
|
||||
guard let stadium = request.stadiums[game.stadiumId] else { return false }
|
||||
let gameRegion = Region.classify(longitude: stadium.coordinate.longitude)
|
||||
let inRegion = selectedRegions.contains(gameRegion)
|
||||
if !inRegion {
|
||||
print("🔍 FILTERED OUT (region): \(stadium.city) on \(game.gameDate) - region=\(gameRegion)")
|
||||
}
|
||||
return inRegion
|
||||
}
|
||||
return true
|
||||
}
|
||||
.sorted { $0.startTime < $1.startTime }
|
||||
|
||||
print("🔍 ScenarioD Step 4 result: \(filteredGames.count) games after date/region filter")
|
||||
for game in filteredGames {
|
||||
let stadium = request.stadiums[game.stadiumId]
|
||||
print("🔍 Kept: \(stadium?.city ?? "?") on \(game.gameDate)")
|
||||
}
|
||||
|
||||
if filteredGames.isEmpty {
|
||||
return .failure(
|
||||
PlanningFailure(
|
||||
reason: .noGamesInRange,
|
||||
violations: [
|
||||
ConstraintViolation(
|
||||
type: .dateRange,
|
||||
description: "No team games found in selected date range and regions",
|
||||
severity: .error
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Step 5: Prepare for routing
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// NOTE: We do NOT filter by repeat city here. The GameDAGRouter handles
|
||||
// allowRepeatCities internally, which allows it to pick the optimal game
|
||||
// per city for route feasibility (e.g., pick July 29 Anaheim instead of
|
||||
// July 27 if it makes the driving from Chicago feasible).
|
||||
let finalGames = filteredGames
|
||||
|
||||
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)")
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Step 6: Find valid routes using DAG router
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Follow Team mode typically has fewer games than Scenario A,
|
||||
// so we can be more exhaustive in route finding.
|
||||
print("🔍 ScenarioD Step 6: Finding routes from \(finalGames.count) games")
|
||||
|
||||
var validRoutes: [[Game]] = []
|
||||
|
||||
let globalRoutes = GameDAGRouter.findAllSensibleRoutes(
|
||||
from: finalGames,
|
||||
stadiums: request.stadiums,
|
||||
allowRepeatCities: request.preferences.allowRepeatCities,
|
||||
stopBuilder: buildStops
|
||||
)
|
||||
print("🔍 ScenarioD Step 6: GameDAGRouter returned \(globalRoutes.count) routes")
|
||||
for (i, route) in globalRoutes.prefix(5).enumerated() {
|
||||
let cities = route.compactMap { request.stadiums[$0.stadiumId]?.city }.joined(separator: " → ")
|
||||
print("🔍 Route \(i+1): \(route.count) games - \(cities)")
|
||||
}
|
||||
|
||||
validRoutes.append(contentsOf: globalRoutes)
|
||||
|
||||
// Deduplicate routes
|
||||
validRoutes = deduplicateRoutes(validRoutes)
|
||||
|
||||
print("🔍 ScenarioD Step 6 after dedup: \(validRoutes.count) valid routes")
|
||||
|
||||
if validRoutes.isEmpty {
|
||||
return .failure(
|
||||
PlanningFailure(
|
||||
reason: .noValidRoutes,
|
||||
violations: [
|
||||
ConstraintViolation(
|
||||
type: .geographicSanity,
|
||||
description: "No geographically sensible route found for team's games",
|
||||
severity: .error
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Step 7: Build itineraries for each valid route
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
var itineraryOptions: [ItineraryOption] = []
|
||||
|
||||
for (index, routeGames) in validRoutes.enumerated() {
|
||||
// Build stops for this route
|
||||
let stops = buildStops(from: routeGames, stadiums: request.stadiums)
|
||||
|
||||
guard !stops.isEmpty else { continue }
|
||||
|
||||
// Calculate travel segments using shared ItineraryBuilder
|
||||
guard let itinerary = ItineraryBuilder.build(
|
||||
stops: stops,
|
||||
constraints: request.drivingConstraints
|
||||
) else {
|
||||
// This route fails driving constraints, skip it
|
||||
continue
|
||||
}
|
||||
|
||||
// Create the option
|
||||
let cities = stops.map { $0.city }.joined(separator: " → ")
|
||||
let option = ItineraryOption(
|
||||
rank: index + 1,
|
||||
stops: itinerary.stops,
|
||||
travelSegments: itinerary.travelSegments,
|
||||
totalDrivingHours: itinerary.totalDrivingHours,
|
||||
totalDistanceMiles: itinerary.totalDistanceMiles,
|
||||
geographicRationale: "Follow Team: \(stops.count) games - \(cities)"
|
||||
)
|
||||
itineraryOptions.append(option)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Step 8: Return ranked results
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
if itineraryOptions.isEmpty {
|
||||
return .failure(
|
||||
PlanningFailure(
|
||||
reason: .constraintsUnsatisfiable,
|
||||
violations: [
|
||||
ConstraintViolation(
|
||||
type: .drivingTime,
|
||||
description: "No routes satisfy driving constraints for team's schedule",
|
||||
severity: .error
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Sort and rank based on leisure level
|
||||
let leisureLevel = request.preferences.leisureLevel
|
||||
let rankedOptions = ItineraryOption.sortByLeisure(
|
||||
itineraryOptions,
|
||||
leisureLevel: leisureLevel
|
||||
)
|
||||
|
||||
print("🔍 ScenarioD: Returning \(rankedOptions.count) options")
|
||||
|
||||
return .success(rankedOptions)
|
||||
}
|
||||
|
||||
// MARK: - Team Filtering
|
||||
|
||||
/// Filters games to those involving the followed team (home or away).
|
||||
private func filterToTeam(_ games: [Game], teamId: UUID) -> [Game] {
|
||||
games.filter { game in
|
||||
game.homeTeamId == teamId || game.awayTeamId == teamId
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Repeat City Filtering
|
||||
|
||||
/// When `allowRepeatCities = false`, keeps only the first game per city.
|
||||
private func applyRepeatCityFilter(
|
||||
_ games: [Game],
|
||||
allowRepeat: Bool,
|
||||
stadiums: [UUID: Stadium]
|
||||
) -> [Game] {
|
||||
guard !allowRepeat else {
|
||||
print("🔍 applyRepeatCityFilter: allowRepeat=true, returning all \(games.count) games")
|
||||
return games
|
||||
}
|
||||
|
||||
print("🔍 applyRepeatCityFilter: allowRepeat=false, filtering duplicates")
|
||||
var seenCities: Set<String> = []
|
||||
return games.filter { game in
|
||||
guard let stadium = stadiums[game.stadiumId] else {
|
||||
print("🔍 Game \(game.id): NO STADIUM FOUND - filtered out")
|
||||
return false
|
||||
}
|
||||
if seenCities.contains(stadium.city) {
|
||||
print("🔍 Game in \(stadium.city) on \(game.gameDate): DUPLICATE CITY - filtered out")
|
||||
return false
|
||||
}
|
||||
print("🔍 Game in \(stadium.city) on \(game.gameDate): KEPT (first in this city)")
|
||||
seenCities.insert(stadium.city)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stop Building
|
||||
|
||||
/// Converts a list of games into itinerary stops.
|
||||
/// Same logic as ScenarioAPlanner.
|
||||
private func buildStops(
|
||||
from games: [Game],
|
||||
stadiums: [UUID: Stadium]
|
||||
) -> [ItineraryStop] {
|
||||
guard !games.isEmpty else { return [] }
|
||||
|
||||
let sortedGames = games.sorted { $0.startTime < $1.startTime }
|
||||
|
||||
var stops: [ItineraryStop] = []
|
||||
var currentStadiumId: UUID? = nil
|
||||
var currentGames: [Game] = []
|
||||
|
||||
for game in sortedGames {
|
||||
if game.stadiumId == currentStadiumId {
|
||||
currentGames.append(game)
|
||||
} else {
|
||||
if let stadiumId = currentStadiumId, !currentGames.isEmpty {
|
||||
if let stop = createStop(from: currentGames, stadiumId: stadiumId, stadiums: stadiums) {
|
||||
stops.append(stop)
|
||||
}
|
||||
}
|
||||
currentStadiumId = game.stadiumId
|
||||
currentGames = [game]
|
||||
}
|
||||
}
|
||||
|
||||
// Don't forget the last group
|
||||
if let stadiumId = currentStadiumId, !currentGames.isEmpty {
|
||||
if let stop = createStop(from: currentGames, stadiumId: stadiumId, stadiums: stadiums) {
|
||||
stops.append(stop)
|
||||
}
|
||||
}
|
||||
|
||||
return stops
|
||||
}
|
||||
|
||||
/// Creates an ItineraryStop from a group of games at the same stadium.
|
||||
private func createStop(
|
||||
from games: [Game],
|
||||
stadiumId: UUID,
|
||||
stadiums: [UUID: Stadium]
|
||||
) -> ItineraryStop? {
|
||||
guard !games.isEmpty else { return nil }
|
||||
|
||||
let sortedGames = games.sorted { $0.startTime < $1.startTime }
|
||||
let stadium = stadiums[stadiumId]
|
||||
let city = stadium?.city ?? "Unknown"
|
||||
let state = stadium?.state ?? ""
|
||||
let coordinate = stadium?.coordinate
|
||||
|
||||
let location = LocationInput(
|
||||
name: city,
|
||||
coordinate: coordinate,
|
||||
address: stadium?.fullAddress
|
||||
)
|
||||
|
||||
let lastGameDate = sortedGames.last?.gameDate ?? Date()
|
||||
|
||||
return ItineraryStop(
|
||||
city: city,
|
||||
state: state,
|
||||
coordinate: coordinate,
|
||||
games: sortedGames.map { $0.id },
|
||||
arrivalDate: sortedGames.first?.gameDate ?? Date(),
|
||||
departureDate: lastGameDate,
|
||||
location: location,
|
||||
firstGameStart: sortedGames.first?.startTime
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Route Deduplication
|
||||
|
||||
/// Removes duplicate routes (routes with identical game IDs).
|
||||
private func deduplicateRoutes(_ routes: [[Game]]) -> [[Game]] {
|
||||
var seen = Set<String>()
|
||||
var unique: [[Game]] = []
|
||||
|
||||
for route in routes {
|
||||
let key = route.map { $0.id.uuidString }.sorted().joined(separator: "-")
|
||||
if !seen.contains(key) {
|
||||
seen.insert(key)
|
||||
unique.append(route)
|
||||
}
|
||||
}
|
||||
|
||||
return unique
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,11 @@ enum ScenarioPlannerFactory {
|
||||
|
||||
/// Creates the appropriate planner based on the request inputs
|
||||
static func planner(for request: PlanningRequest) -> ScenarioPlanner {
|
||||
// Scenario D: User wants to follow a specific team
|
||||
if request.preferences.followTeamId != nil {
|
||||
return ScenarioDPlanner()
|
||||
}
|
||||
|
||||
// Scenario B: User selected specific games
|
||||
if !request.selectedGames.isEmpty {
|
||||
return ScenarioBPlanner()
|
||||
@@ -38,6 +43,9 @@ enum ScenarioPlannerFactory {
|
||||
|
||||
/// Classifies which scenario applies to this request
|
||||
static func classify(_ request: PlanningRequest) -> PlanningScenario {
|
||||
if request.preferences.followTeamId != nil {
|
||||
return .scenarioD
|
||||
}
|
||||
if !request.selectedGames.isEmpty {
|
||||
return .scenarioB
|
||||
}
|
||||
|
||||
@@ -30,8 +30,9 @@ enum TravelEstimator {
|
||||
let distanceMiles = calculateDistanceMiles(from: from, to: to)
|
||||
let drivingHours = distanceMiles / averageSpeedMph
|
||||
|
||||
// Maximum allowed: 2 days of driving
|
||||
let maxAllowedHours = constraints.maxDailyDrivingHours * 2.0
|
||||
// Maximum allowed: 5 days of driving (matches GameDAGRouter.maxDayLookahead)
|
||||
// This allows multi-day cross-country segments like Chicago → Anaheim
|
||||
let maxAllowedHours = constraints.maxDailyDrivingHours * 5.0
|
||||
if drivingHours > maxAllowedHours {
|
||||
return nil
|
||||
}
|
||||
@@ -62,8 +63,9 @@ enum TravelEstimator {
|
||||
let distanceMiles = distanceMeters * 0.000621371 * roadRoutingFactor
|
||||
let drivingHours = distanceMiles / averageSpeedMph
|
||||
|
||||
// Maximum allowed: 2 days of driving
|
||||
let maxAllowedHours = constraints.maxDailyDrivingHours * 2.0
|
||||
// Maximum allowed: 5 days of driving (matches GameDAGRouter.maxDayLookahead)
|
||||
// This allows multi-day cross-country segments like Chicago → Anaheim
|
||||
let maxAllowedHours = constraints.maxDailyDrivingHours * 5.0
|
||||
if drivingHours > maxAllowedHours {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ enum PlanningScenario: Equatable {
|
||||
case scenarioA // Date range only
|
||||
case scenarioB // Selected games + date range
|
||||
case scenarioC // Start + end locations
|
||||
case scenarioD // Follow team schedule
|
||||
}
|
||||
|
||||
// MARK: - Planning Failure
|
||||
@@ -29,6 +30,7 @@ struct PlanningFailure: Error {
|
||||
case noValidRoutes
|
||||
case missingDateRange
|
||||
case missingLocations
|
||||
case missingTeamSelection
|
||||
case dateRangeViolation(games: [Game])
|
||||
case drivingExceedsLimit
|
||||
case cannotArriveInTime
|
||||
@@ -43,6 +45,7 @@ struct PlanningFailure: Error {
|
||||
(.noValidRoutes, .noValidRoutes),
|
||||
(.missingDateRange, .missingDateRange),
|
||||
(.missingLocations, .missingLocations),
|
||||
(.missingTeamSelection, .missingTeamSelection),
|
||||
(.drivingExceedsLimit, .drivingExceedsLimit),
|
||||
(.cannotArriveInTime, .cannotArriveInTime),
|
||||
(.travelSegmentMissing, .travelSegmentMissing),
|
||||
@@ -70,6 +73,7 @@ struct PlanningFailure: Error {
|
||||
case .noValidRoutes: return "No valid routes could be constructed"
|
||||
case .missingDateRange: return "Date range is required"
|
||||
case .missingLocations: return "Start and end locations are required"
|
||||
case .missingTeamSelection: return "Select a team to follow"
|
||||
case .dateRangeViolation(let games):
|
||||
return "\(games.count) selected game(s) fall outside the date range"
|
||||
case .drivingExceedsLimit: return "Driving time exceeds daily limit"
|
||||
@@ -512,9 +516,15 @@ struct PlanningRequest {
|
||||
}
|
||||
|
||||
/// Date range as DateInterval
|
||||
/// Note: End date is extended to end-of-day to include all games on the last day,
|
||||
/// since DateInterval.contains() uses exclusive end boundary.
|
||||
var dateRange: DateInterval? {
|
||||
guard preferences.endDate > preferences.startDate else { return nil }
|
||||
return DateInterval(start: preferences.startDate, end: preferences.endDate)
|
||||
// Extend end date to end of day (23:59:59) to include games on the last day
|
||||
let calendar = Calendar.current
|
||||
let endOfDay = calendar.date(bySettingHour: 23, minute: 59, second: 59, of: preferences.endDate)
|
||||
?? preferences.endDate
|
||||
return DateInterval(start: preferences.startDate, end: endOfDay)
|
||||
}
|
||||
|
||||
/// First must-stop location (if any)
|
||||
|
||||
1009
SportsTimeTests/Planning/ScenarioDPlannerTests.swift
Normal file
1009
SportsTimeTests/Planning/ScenarioDPlannerTests.swift
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user