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:
Trey t
2026-01-11 19:19:46 -06:00
parent 3f80a16201
commit dcd5edb229
4 changed files with 64 additions and 8 deletions

View File

@@ -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 {

View File

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

View File

@@ -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:

View File

@@ -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