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

View File

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