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