feat: add sliding window trip generation for By Games mode
Adds a trip duration stepper (2-21 days) to the By Games planning mode. When users select specific games, the planner now generates ALL possible N-day windows containing those games (e.g., 7-day trips starting on different days), finding additional games in each window to maximize route options. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -240,6 +240,9 @@ struct TripPreferences: Codable, Hashable {
|
|||||||
/// Whether to start/end from a home location (vs fly-in/fly-out)
|
/// Whether to start/end from a home location (vs fly-in/fly-out)
|
||||||
var useHomeLocation: Bool
|
var useHomeLocation: Bool
|
||||||
|
|
||||||
|
/// Trip duration for gameFirst mode sliding windows (number of days)
|
||||||
|
var gameFirstTripDuration: Int
|
||||||
|
|
||||||
init(
|
init(
|
||||||
planningMode: PlanningMode = .dateRange,
|
planningMode: PlanningMode = .dateRange,
|
||||||
startLocation: LocationInput? = nil,
|
startLocation: LocationInput? = nil,
|
||||||
@@ -262,7 +265,8 @@ struct TripPreferences: Codable, Hashable {
|
|||||||
allowRepeatCities: Bool = true,
|
allowRepeatCities: Bool = true,
|
||||||
selectedRegions: Set<Region> = [.east, .central, .west],
|
selectedRegions: Set<Region> = [.east, .central, .west],
|
||||||
followTeamId: UUID? = nil,
|
followTeamId: UUID? = nil,
|
||||||
useHomeLocation: Bool = true
|
useHomeLocation: Bool = true,
|
||||||
|
gameFirstTripDuration: Int = 7
|
||||||
) {
|
) {
|
||||||
self.planningMode = planningMode
|
self.planningMode = planningMode
|
||||||
self.startLocation = startLocation
|
self.startLocation = startLocation
|
||||||
@@ -286,6 +290,7 @@ struct TripPreferences: Codable, Hashable {
|
|||||||
self.selectedRegions = selectedRegions
|
self.selectedRegions = selectedRegions
|
||||||
self.followTeamId = followTeamId
|
self.followTeamId = followTeamId
|
||||||
self.useHomeLocation = useHomeLocation
|
self.useHomeLocation = useHomeLocation
|
||||||
|
self.gameFirstTripDuration = gameFirstTripDuration
|
||||||
}
|
}
|
||||||
|
|
||||||
var totalDriverHoursPerDay: Double {
|
var totalDriverHoursPerDay: Double {
|
||||||
|
|||||||
@@ -137,6 +137,9 @@ final class TripCreationViewModel {
|
|||||||
var followTeamId: UUID?
|
var followTeamId: UUID?
|
||||||
var useHomeLocation: Bool = true
|
var useHomeLocation: Bool = true
|
||||||
|
|
||||||
|
// Game First Mode - Trip duration for sliding windows
|
||||||
|
var gameFirstTripDuration: Int = 7
|
||||||
|
|
||||||
// MARK: - Dependencies
|
// MARK: - Dependencies
|
||||||
|
|
||||||
private let planningEngine = TripPlanningEngine()
|
private let planningEngine = TripPlanningEngine()
|
||||||
@@ -407,7 +410,8 @@ final class TripCreationViewModel {
|
|||||||
allowRepeatCities: allowRepeatCities,
|
allowRepeatCities: allowRepeatCities,
|
||||||
selectedRegions: selectedRegions,
|
selectedRegions: selectedRegions,
|
||||||
followTeamId: followTeamId,
|
followTeamId: followTeamId,
|
||||||
useHomeLocation: useHomeLocation
|
useHomeLocation: useHomeLocation,
|
||||||
|
gameFirstTripDuration: gameFirstTripDuration
|
||||||
)
|
)
|
||||||
|
|
||||||
// Build planning request
|
// Build planning request
|
||||||
@@ -589,7 +593,10 @@ final class TripCreationViewModel {
|
|||||||
numberOfDrivers: numberOfDrivers,
|
numberOfDrivers: numberOfDrivers,
|
||||||
maxDrivingHoursPerDriver: maxDrivingHoursPerDriver,
|
maxDrivingHoursPerDriver: maxDrivingHoursPerDriver,
|
||||||
allowRepeatCities: allowRepeatCities,
|
allowRepeatCities: allowRepeatCities,
|
||||||
selectedRegions: selectedRegions
|
selectedRegions: selectedRegions,
|
||||||
|
followTeamId: followTeamId,
|
||||||
|
useHomeLocation: useHomeLocation,
|
||||||
|
gameFirstTripDuration: gameFirstTripDuration
|
||||||
)
|
)
|
||||||
return convertToTrip(option: option, preferences: preferences)
|
return convertToTrip(option: option, preferences: preferences)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,9 +66,10 @@ struct TripCreationView: View {
|
|||||||
datesSection
|
datesSection
|
||||||
|
|
||||||
case .gameFirst:
|
case .gameFirst:
|
||||||
// Sports + Game Picker
|
// Sports + Game Picker + Trip Duration
|
||||||
sportsSection
|
sportsSection
|
||||||
gameBrowserSection
|
gameBrowserSection
|
||||||
|
tripDurationSection
|
||||||
|
|
||||||
case .locations:
|
case .locations:
|
||||||
// Locations + Sports + optional games
|
// Locations + Sports + optional games
|
||||||
@@ -512,6 +513,39 @@ struct TripCreationView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var tripDurationSection: some View {
|
||||||
|
ThemedSection(title: "Trip Duration") {
|
||||||
|
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "calendar.badge.clock")
|
||||||
|
.foregroundStyle(Theme.warmOrange)
|
||||||
|
Text("Days")
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Stepper(
|
||||||
|
value: $viewModel.gameFirstTripDuration,
|
||||||
|
in: 2...21,
|
||||||
|
step: 1
|
||||||
|
) {
|
||||||
|
Text("\(viewModel.gameFirstTripDuration) days")
|
||||||
|
.font(.body.monospacedDigit())
|
||||||
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(Theme.Spacing.md)
|
||||||
|
.background(Theme.cardBackgroundElevated(colorScheme))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||||
|
|
||||||
|
Text("We'll find all possible \(viewModel.gameFirstTripDuration)-day trips that include your selected games")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private var sportsSection: some View {
|
private var sportsSection: some View {
|
||||||
ThemedSection(title: "Sports") {
|
ThemedSection(title: "Sports") {
|
||||||
SportSelectorGrid(
|
SportSelectorGrid(
|
||||||
@@ -1406,6 +1440,7 @@ struct LocationSearchSheet: View {
|
|||||||
|
|
||||||
enum TripSortOption: String, CaseIterable, Identifiable {
|
enum TripSortOption: String, CaseIterable, Identifiable {
|
||||||
case recommended = "Recommended"
|
case recommended = "Recommended"
|
||||||
|
case mostCities = "Most Cities"
|
||||||
case mostGames = "Most Games"
|
case mostGames = "Most Games"
|
||||||
case leastGames = "Least Games"
|
case leastGames = "Least Games"
|
||||||
case mostMiles = "Most Miles"
|
case mostMiles = "Most Miles"
|
||||||
@@ -1417,6 +1452,7 @@ enum TripSortOption: String, CaseIterable, Identifiable {
|
|||||||
var icon: String {
|
var icon: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .recommended: return "star.fill"
|
case .recommended: return "star.fill"
|
||||||
|
case .mostCities: return "mappin.and.ellipse"
|
||||||
case .mostGames, .leastGames: return "sportscourt"
|
case .mostGames, .leastGames: return "sportscourt"
|
||||||
case .mostMiles, .leastMiles: return "road.lanes"
|
case .mostMiles, .leastMiles: return "road.lanes"
|
||||||
case .bestEfficiency: return "gauge.with.dots.needle.33percent"
|
case .bestEfficiency: return "gauge.with.dots.needle.33percent"
|
||||||
@@ -1514,6 +1550,8 @@ struct TripOptionsView: View {
|
|||||||
switch sortOption {
|
switch sortOption {
|
||||||
case .recommended:
|
case .recommended:
|
||||||
return filtered
|
return filtered
|
||||||
|
case .mostCities:
|
||||||
|
return filtered.sorted { $0.stops.count > $1.stops.count }
|
||||||
case .mostGames:
|
case .mostGames:
|
||||||
return filtered.sorted { $0.totalGames > $1.totalGames }
|
return filtered.sorted { $0.totalGames > $1.totalGames }
|
||||||
case .leastGames:
|
case .leastGames:
|
||||||
|
|||||||
@@ -221,13 +221,19 @@ final class ScenarioBPlanner: ScenarioPlanner {
|
|||||||
request: PlanningRequest
|
request: PlanningRequest
|
||||||
) -> [DateInterval] {
|
) -> [DateInterval] {
|
||||||
|
|
||||||
// If explicit date range exists, use it
|
// For gameFirst mode, ALWAYS use sliding windows with gameFirstTripDuration
|
||||||
if let dateRange = request.dateRange {
|
// This generates all possible N-day windows containing the selected games
|
||||||
|
let isGameFirstMode = request.preferences.planningMode == .gameFirst
|
||||||
|
let duration = isGameFirstMode
|
||||||
|
? request.preferences.gameFirstTripDuration
|
||||||
|
: request.preferences.effectiveTripDuration
|
||||||
|
|
||||||
|
// If not gameFirst and explicit date range exists, use it directly
|
||||||
|
if !isGameFirstMode, let dateRange = request.dateRange {
|
||||||
return [dateRange]
|
return [dateRange]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, use trip duration to create sliding windows
|
// Use trip duration to create sliding windows
|
||||||
let duration = request.preferences.effectiveTripDuration
|
|
||||||
guard duration > 0 else { return [] }
|
guard duration > 0 else { return [] }
|
||||||
|
|
||||||
// Find the span of selected games
|
// Find the span of selected games
|
||||||
|
|||||||
Reference in New Issue
Block a user