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)
|
||||
var useHomeLocation: Bool
|
||||
|
||||
/// Trip duration for gameFirst mode sliding windows (number of days)
|
||||
var gameFirstTripDuration: Int
|
||||
|
||||
init(
|
||||
planningMode: PlanningMode = .dateRange,
|
||||
startLocation: LocationInput? = nil,
|
||||
@@ -262,7 +265,8 @@ struct TripPreferences: Codable, Hashable {
|
||||
allowRepeatCities: Bool = true,
|
||||
selectedRegions: Set<Region> = [.east, .central, .west],
|
||||
followTeamId: UUID? = nil,
|
||||
useHomeLocation: Bool = true
|
||||
useHomeLocation: Bool = true,
|
||||
gameFirstTripDuration: Int = 7
|
||||
) {
|
||||
self.planningMode = planningMode
|
||||
self.startLocation = startLocation
|
||||
@@ -286,6 +290,7 @@ struct TripPreferences: Codable, Hashable {
|
||||
self.selectedRegions = selectedRegions
|
||||
self.followTeamId = followTeamId
|
||||
self.useHomeLocation = useHomeLocation
|
||||
self.gameFirstTripDuration = gameFirstTripDuration
|
||||
}
|
||||
|
||||
var totalDriverHoursPerDay: Double {
|
||||
|
||||
@@ -137,6 +137,9 @@ final class TripCreationViewModel {
|
||||
var followTeamId: UUID?
|
||||
var useHomeLocation: Bool = true
|
||||
|
||||
// Game First Mode - Trip duration for sliding windows
|
||||
var gameFirstTripDuration: Int = 7
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
private let planningEngine = TripPlanningEngine()
|
||||
@@ -407,7 +410,8 @@ final class TripCreationViewModel {
|
||||
allowRepeatCities: allowRepeatCities,
|
||||
selectedRegions: selectedRegions,
|
||||
followTeamId: followTeamId,
|
||||
useHomeLocation: useHomeLocation
|
||||
useHomeLocation: useHomeLocation,
|
||||
gameFirstTripDuration: gameFirstTripDuration
|
||||
)
|
||||
|
||||
// Build planning request
|
||||
@@ -589,7 +593,10 @@ final class TripCreationViewModel {
|
||||
numberOfDrivers: numberOfDrivers,
|
||||
maxDrivingHoursPerDriver: maxDrivingHoursPerDriver,
|
||||
allowRepeatCities: allowRepeatCities,
|
||||
selectedRegions: selectedRegions
|
||||
selectedRegions: selectedRegions,
|
||||
followTeamId: followTeamId,
|
||||
useHomeLocation: useHomeLocation,
|
||||
gameFirstTripDuration: gameFirstTripDuration
|
||||
)
|
||||
return convertToTrip(option: option, preferences: preferences)
|
||||
}
|
||||
|
||||
@@ -66,9 +66,10 @@ struct TripCreationView: View {
|
||||
datesSection
|
||||
|
||||
case .gameFirst:
|
||||
// Sports + Game Picker
|
||||
// Sports + Game Picker + Trip Duration
|
||||
sportsSection
|
||||
gameBrowserSection
|
||||
tripDurationSection
|
||||
|
||||
case .locations:
|
||||
// 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 {
|
||||
ThemedSection(title: "Sports") {
|
||||
SportSelectorGrid(
|
||||
@@ -1406,6 +1440,7 @@ struct LocationSearchSheet: View {
|
||||
|
||||
enum TripSortOption: String, CaseIterable, Identifiable {
|
||||
case recommended = "Recommended"
|
||||
case mostCities = "Most Cities"
|
||||
case mostGames = "Most Games"
|
||||
case leastGames = "Least Games"
|
||||
case mostMiles = "Most Miles"
|
||||
@@ -1417,6 +1452,7 @@ enum TripSortOption: String, CaseIterable, Identifiable {
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .recommended: return "star.fill"
|
||||
case .mostCities: return "mappin.and.ellipse"
|
||||
case .mostGames, .leastGames: return "sportscourt"
|
||||
case .mostMiles, .leastMiles: return "road.lanes"
|
||||
case .bestEfficiency: return "gauge.with.dots.needle.33percent"
|
||||
@@ -1514,6 +1550,8 @@ struct TripOptionsView: View {
|
||||
switch sortOption {
|
||||
case .recommended:
|
||||
return filtered
|
||||
case .mostCities:
|
||||
return filtered.sorted { $0.stops.count > $1.stops.count }
|
||||
case .mostGames:
|
||||
return filtered.sorted { $0.totalGames > $1.totalGames }
|
||||
case .leastGames:
|
||||
|
||||
@@ -221,13 +221,19 @@ final class ScenarioBPlanner: ScenarioPlanner {
|
||||
request: PlanningRequest
|
||||
) -> [DateInterval] {
|
||||
|
||||
// If explicit date range exists, use it
|
||||
if let dateRange = request.dateRange {
|
||||
// For gameFirst mode, ALWAYS use sliding windows with gameFirstTripDuration
|
||||
// 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]
|
||||
}
|
||||
|
||||
// Otherwise, use trip duration to create sliding windows
|
||||
let duration = request.preferences.effectiveTripDuration
|
||||
// Use trip duration to create sliding windows
|
||||
guard duration > 0 else { return [] }
|
||||
|
||||
// Find the span of selected games
|
||||
|
||||
Reference in New Issue
Block a user