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:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user