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:
Trey t
2026-01-11 12:42:43 -06:00
parent e7fb3cfbbe
commit f7faec01b1
9 changed files with 1744 additions and 8 deletions

View File

@@ -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()
}
}