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

@@ -546,7 +546,15 @@ enum GameDAGRouter {
? max(0, availableHours)
: Double(daysBetween) * constraints.maxDailyDrivingHours
return drivingHours <= maxDrivingHoursAvailable && drivingHours <= availableHours
let feasible = drivingHours <= maxDrivingHoursAvailable && drivingHours <= availableHours
// Debug output for rejected transitions
if !feasible && drivingHours > 10 {
print("🔍 DAG canTransition REJECTED: \(fromStadium.city)\(toStadium.city)")
print("🔍 drivingHours=\(String(format: "%.1f", drivingHours)), daysBetween=\(daysBetween), maxAvailable=\(String(format: "%.1f", maxDrivingHoursAvailable)), availableHours=\(String(format: "%.1f", availableHours))")
}
return feasible
}
// MARK: - Distance Estimation

View File

@@ -0,0 +1,405 @@
//
// ScenarioDPlanner.swift
// SportsTime
//
// Scenario D: Follow Team planning.
// User selects a team, we find all their games (home and away) and build routes.
//
import Foundation
import CoreLocation
/// Scenario D: Follow Team planning
///
/// This scenario builds trips around a specific team's schedule.
///
/// Input:
/// - followTeamId: Required. The team to follow.
/// - date_range: Required. The trip dates.
/// - selectedRegions: Optional. Filter to specific regions.
/// - useHomeLocation: Whether to start/end from user's home.
/// - startLocation: Required if useHomeLocation is true.
///
/// Output:
/// - Success: Ranked list of itinerary options
/// - Failure: Explicit error with reason (no team, no games, etc.)
///
/// Example:
/// User follows Yankees, Jan 5-15, 2026
/// We find: @Red Sox (Jan 5), @Blue Jays (Jan 8), vs Orioles (Jan 12)
/// Output: Route visiting Boston Toronto New York
///
final class ScenarioDPlanner: ScenarioPlanner {
// MARK: - ScenarioPlanner Protocol
/// Main entry point for Scenario D planning.
///
/// Flow:
/// 1. Validate inputs (team must be selected)
/// 2. Filter games to team's schedule (home and away)
/// 3. Apply region and date filters
/// 4. Apply repeat city constraints
/// 5. Build routes and calculate travel
/// 6. Return ranked itineraries
///
/// Failure cases:
/// - No team selected .missingTeamSelection
/// - No date range .missingDateRange
/// - No games found .noGamesInRange
/// - Can't build valid route .constraintsUnsatisfiable
///
func plan(request: PlanningRequest) -> ItineraryResult {
//
// Step 1: Validate team selection
//
guard let teamId = request.preferences.followTeamId else {
return .failure(
PlanningFailure(
reason: .missingTeamSelection,
violations: []
)
)
}
//
// Step 2: Validate date range exists
//
guard let dateRange = request.dateRange else {
return .failure(
PlanningFailure(
reason: .missingDateRange,
violations: []
)
)
}
//
// Step 3: Filter games to team's schedule (home and away)
//
let teamGames = filterToTeam(request.allGames, teamId: teamId)
print("🔍 ScenarioD Step 3: allGames=\(request.allGames.count), teamGames=\(teamGames.count)")
print("🔍 ScenarioD: Looking for teamId=\(teamId)")
for game in teamGames.prefix(20) {
let stadium = request.stadiums[game.stadiumId]
let isHome = game.homeTeamId == teamId
print("🔍 Game: \(stadium?.city ?? "?") on \(game.gameDate) (\(isHome ? "HOME" : "AWAY"))")
}
if teamGames.isEmpty {
return .failure(
PlanningFailure(
reason: .noGamesInRange,
violations: [
ConstraintViolation(
type: .selectedGames,
description: "No games found for selected team",
severity: .error
)
]
)
)
}
//
// Step 4: Apply date range and region filters
//
let selectedRegions = request.preferences.selectedRegions
print("🔍 ScenarioD Step 4: dateRange=\(dateRange.start) to \(dateRange.end), selectedRegions=\(selectedRegions)")
let filteredGames = teamGames
.filter { game in
// Must be in date range
let inDateRange = dateRange.contains(game.startTime)
if !inDateRange {
let stadium = request.stadiums[game.stadiumId]
print("🔍 FILTERED OUT (date): \(stadium?.city ?? "?") on \(game.gameDate) - startTime=\(game.startTime)")
return false
}
// Must be in selected region (if regions specified)
if !selectedRegions.isEmpty {
guard let stadium = request.stadiums[game.stadiumId] else { return false }
let gameRegion = Region.classify(longitude: stadium.coordinate.longitude)
let inRegion = selectedRegions.contains(gameRegion)
if !inRegion {
print("🔍 FILTERED OUT (region): \(stadium.city) on \(game.gameDate) - region=\(gameRegion)")
}
return inRegion
}
return true
}
.sorted { $0.startTime < $1.startTime }
print("🔍 ScenarioD Step 4 result: \(filteredGames.count) games after date/region filter")
for game in filteredGames {
let stadium = request.stadiums[game.stadiumId]
print("🔍 Kept: \(stadium?.city ?? "?") on \(game.gameDate)")
}
if filteredGames.isEmpty {
return .failure(
PlanningFailure(
reason: .noGamesInRange,
violations: [
ConstraintViolation(
type: .dateRange,
description: "No team games found in selected date range and regions",
severity: .error
)
]
)
)
}
//
// Step 5: Prepare for routing
//
// NOTE: We do NOT filter by repeat city here. The GameDAGRouter handles
// allowRepeatCities internally, which allows it to pick the optimal game
// per city for route feasibility (e.g., pick July 29 Anaheim instead of
// July 27 if it makes the driving from Chicago feasible).
let finalGames = filteredGames
print("🔍 ScenarioD Step 5: Passing \(finalGames.count) games to router (allowRepeatCities=\(request.preferences.allowRepeatCities))")
print("🔍 ScenarioD: teamGames=\(teamGames.count), filteredGames=\(filteredGames.count), finalGames=\(finalGames.count)")
//
// Step 6: Find valid routes using DAG router
//
// Follow Team mode typically has fewer games than Scenario A,
// so we can be more exhaustive in route finding.
print("🔍 ScenarioD Step 6: Finding routes from \(finalGames.count) games")
var validRoutes: [[Game]] = []
let globalRoutes = GameDAGRouter.findAllSensibleRoutes(
from: finalGames,
stadiums: request.stadiums,
allowRepeatCities: request.preferences.allowRepeatCities,
stopBuilder: buildStops
)
print("🔍 ScenarioD Step 6: GameDAGRouter returned \(globalRoutes.count) routes")
for (i, route) in globalRoutes.prefix(5).enumerated() {
let cities = route.compactMap { request.stadiums[$0.stadiumId]?.city }.joined(separator: "")
print("🔍 Route \(i+1): \(route.count) games - \(cities)")
}
validRoutes.append(contentsOf: globalRoutes)
// Deduplicate routes
validRoutes = deduplicateRoutes(validRoutes)
print("🔍 ScenarioD Step 6 after dedup: \(validRoutes.count) valid routes")
if validRoutes.isEmpty {
return .failure(
PlanningFailure(
reason: .noValidRoutes,
violations: [
ConstraintViolation(
type: .geographicSanity,
description: "No geographically sensible route found for team's games",
severity: .error
)
]
)
)
}
//
// Step 7: Build itineraries for each valid route
//
var itineraryOptions: [ItineraryOption] = []
for (index, routeGames) in validRoutes.enumerated() {
// Build stops for this route
let stops = buildStops(from: routeGames, stadiums: request.stadiums)
guard !stops.isEmpty else { continue }
// Calculate travel segments using shared ItineraryBuilder
guard let itinerary = ItineraryBuilder.build(
stops: stops,
constraints: request.drivingConstraints
) else {
// This route fails driving constraints, skip it
continue
}
// Create the option
let cities = stops.map { $0.city }.joined(separator: "")
let option = ItineraryOption(
rank: index + 1,
stops: itinerary.stops,
travelSegments: itinerary.travelSegments,
totalDrivingHours: itinerary.totalDrivingHours,
totalDistanceMiles: itinerary.totalDistanceMiles,
geographicRationale: "Follow Team: \(stops.count) games - \(cities)"
)
itineraryOptions.append(option)
}
//
// Step 8: Return ranked results
//
if itineraryOptions.isEmpty {
return .failure(
PlanningFailure(
reason: .constraintsUnsatisfiable,
violations: [
ConstraintViolation(
type: .drivingTime,
description: "No routes satisfy driving constraints for team's schedule",
severity: .error
)
]
)
)
}
// Sort and rank based on leisure level
let leisureLevel = request.preferences.leisureLevel
let rankedOptions = ItineraryOption.sortByLeisure(
itineraryOptions,
leisureLevel: leisureLevel
)
print("🔍 ScenarioD: Returning \(rankedOptions.count) options")
return .success(rankedOptions)
}
// MARK: - Team Filtering
/// Filters games to those involving the followed team (home or away).
private func filterToTeam(_ games: [Game], teamId: UUID) -> [Game] {
games.filter { game in
game.homeTeamId == teamId || game.awayTeamId == teamId
}
}
// MARK: - Repeat City Filtering
/// When `allowRepeatCities = false`, keeps only the first game per city.
private func applyRepeatCityFilter(
_ games: [Game],
allowRepeat: Bool,
stadiums: [UUID: Stadium]
) -> [Game] {
guard !allowRepeat else {
print("🔍 applyRepeatCityFilter: allowRepeat=true, returning all \(games.count) games")
return games
}
print("🔍 applyRepeatCityFilter: allowRepeat=false, filtering duplicates")
var seenCities: Set<String> = []
return games.filter { game in
guard let stadium = stadiums[game.stadiumId] else {
print("🔍 Game \(game.id): NO STADIUM FOUND - filtered out")
return false
}
if seenCities.contains(stadium.city) {
print("🔍 Game in \(stadium.city) on \(game.gameDate): DUPLICATE CITY - filtered out")
return false
}
print("🔍 Game in \(stadium.city) on \(game.gameDate): KEPT (first in this city)")
seenCities.insert(stadium.city)
return true
}
}
// MARK: - Stop Building
/// Converts a list of games into itinerary stops.
/// Same logic as ScenarioAPlanner.
private func buildStops(
from games: [Game],
stadiums: [UUID: Stadium]
) -> [ItineraryStop] {
guard !games.isEmpty else { return [] }
let sortedGames = games.sorted { $0.startTime < $1.startTime }
var stops: [ItineraryStop] = []
var currentStadiumId: UUID? = nil
var currentGames: [Game] = []
for game in sortedGames {
if game.stadiumId == currentStadiumId {
currentGames.append(game)
} else {
if let stadiumId = currentStadiumId, !currentGames.isEmpty {
if let stop = createStop(from: currentGames, stadiumId: stadiumId, stadiums: stadiums) {
stops.append(stop)
}
}
currentStadiumId = game.stadiumId
currentGames = [game]
}
}
// Don't forget the last group
if let stadiumId = currentStadiumId, !currentGames.isEmpty {
if let stop = createStop(from: currentGames, stadiumId: stadiumId, stadiums: stadiums) {
stops.append(stop)
}
}
return stops
}
/// Creates an ItineraryStop from a group of games at the same stadium.
private func createStop(
from games: [Game],
stadiumId: UUID,
stadiums: [UUID: Stadium]
) -> ItineraryStop? {
guard !games.isEmpty else { return nil }
let sortedGames = games.sorted { $0.startTime < $1.startTime }
let stadium = stadiums[stadiumId]
let city = stadium?.city ?? "Unknown"
let state = stadium?.state ?? ""
let coordinate = stadium?.coordinate
let location = LocationInput(
name: city,
coordinate: coordinate,
address: stadium?.fullAddress
)
let lastGameDate = sortedGames.last?.gameDate ?? Date()
return ItineraryStop(
city: city,
state: state,
coordinate: coordinate,
games: sortedGames.map { $0.id },
arrivalDate: sortedGames.first?.gameDate ?? Date(),
departureDate: lastGameDate,
location: location,
firstGameStart: sortedGames.first?.startTime
)
}
// MARK: - Route Deduplication
/// Removes duplicate routes (routes with identical game IDs).
private func deduplicateRoutes(_ routes: [[Game]]) -> [[Game]] {
var seen = Set<String>()
var unique: [[Game]] = []
for route in routes {
let key = route.map { $0.id.uuidString }.sorted().joined(separator: "-")
if !seen.contains(key) {
seen.insert(key)
unique.append(route)
}
}
return unique
}
}

View File

@@ -22,6 +22,11 @@ enum ScenarioPlannerFactory {
/// Creates the appropriate planner based on the request inputs
static func planner(for request: PlanningRequest) -> ScenarioPlanner {
// Scenario D: User wants to follow a specific team
if request.preferences.followTeamId != nil {
return ScenarioDPlanner()
}
// Scenario B: User selected specific games
if !request.selectedGames.isEmpty {
return ScenarioBPlanner()
@@ -38,6 +43,9 @@ enum ScenarioPlannerFactory {
/// Classifies which scenario applies to this request
static func classify(_ request: PlanningRequest) -> PlanningScenario {
if request.preferences.followTeamId != nil {
return .scenarioD
}
if !request.selectedGames.isEmpty {
return .scenarioB
}

View File

@@ -30,8 +30,9 @@ enum TravelEstimator {
let distanceMiles = calculateDistanceMiles(from: from, to: to)
let drivingHours = distanceMiles / averageSpeedMph
// Maximum allowed: 2 days of driving
let maxAllowedHours = constraints.maxDailyDrivingHours * 2.0
// Maximum allowed: 5 days of driving (matches GameDAGRouter.maxDayLookahead)
// This allows multi-day cross-country segments like Chicago Anaheim
let maxAllowedHours = constraints.maxDailyDrivingHours * 5.0
if drivingHours > maxAllowedHours {
return nil
}
@@ -62,8 +63,9 @@ enum TravelEstimator {
let distanceMiles = distanceMeters * 0.000621371 * roadRoutingFactor
let drivingHours = distanceMiles / averageSpeedMph
// Maximum allowed: 2 days of driving
let maxAllowedHours = constraints.maxDailyDrivingHours * 2.0
// Maximum allowed: 5 days of driving (matches GameDAGRouter.maxDayLookahead)
// This allows multi-day cross-country segments like Chicago Anaheim
let maxAllowedHours = constraints.maxDailyDrivingHours * 5.0
if drivingHours > maxAllowedHours {
return nil
}

View File

@@ -15,6 +15,7 @@ enum PlanningScenario: Equatable {
case scenarioA // Date range only
case scenarioB // Selected games + date range
case scenarioC // Start + end locations
case scenarioD // Follow team schedule
}
// MARK: - Planning Failure
@@ -29,6 +30,7 @@ struct PlanningFailure: Error {
case noValidRoutes
case missingDateRange
case missingLocations
case missingTeamSelection
case dateRangeViolation(games: [Game])
case drivingExceedsLimit
case cannotArriveInTime
@@ -43,6 +45,7 @@ struct PlanningFailure: Error {
(.noValidRoutes, .noValidRoutes),
(.missingDateRange, .missingDateRange),
(.missingLocations, .missingLocations),
(.missingTeamSelection, .missingTeamSelection),
(.drivingExceedsLimit, .drivingExceedsLimit),
(.cannotArriveInTime, .cannotArriveInTime),
(.travelSegmentMissing, .travelSegmentMissing),
@@ -70,6 +73,7 @@ struct PlanningFailure: Error {
case .noValidRoutes: return "No valid routes could be constructed"
case .missingDateRange: return "Date range is required"
case .missingLocations: return "Start and end locations are required"
case .missingTeamSelection: return "Select a team to follow"
case .dateRangeViolation(let games):
return "\(games.count) selected game(s) fall outside the date range"
case .drivingExceedsLimit: return "Driving time exceeds daily limit"
@@ -512,9 +516,15 @@ struct PlanningRequest {
}
/// Date range as DateInterval
/// Note: End date is extended to end-of-day to include all games on the last day,
/// since DateInterval.contains() uses exclusive end boundary.
var dateRange: DateInterval? {
guard preferences.endDate > preferences.startDate else { return nil }
return DateInterval(start: preferences.startDate, end: preferences.endDate)
// Extend end date to end of day (23:59:59) to include games on the last day
let calendar = Calendar.current
let endOfDay = calendar.date(bySettingHour: 23, minute: 59, second: 59, of: preferences.endDate)
?? preferences.endDate
return DateInterval(start: preferences.startDate, end: endOfDay)
}
/// First must-stop location (if any)