chore: remove scraper, add docs, add marketing-videos gitignore
- Remove Scripts/ directory (scraper no longer needed) - Add themed background documentation to CLAUDE.md - Add .gitignore for marketing-videos to prevent node_modules tracking Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
498
SportsTime/Planning/Engine/ScenarioEPlanner.swift
Normal file
498
SportsTime/Planning/Engine/ScenarioEPlanner.swift
Normal file
@@ -0,0 +1,498 @@
|
||||
//
|
||||
// ScenarioEPlanner.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Scenario E: Team-First planning.
|
||||
// User selects teams (not dates), we find optimal trip windows across the season.
|
||||
//
|
||||
// Key Features:
|
||||
// - Selected teams determine which home games to visit
|
||||
// - Sliding window logic generates all N-day windows across the season
|
||||
// - Windows are filtered to those where ALL selected teams have home games
|
||||
// - Routes are built and ranked by duration + miles
|
||||
//
|
||||
// Sliding Window Algorithm:
|
||||
// 1. Fetch all home games for selected teams (full season)
|
||||
// 2. Generate all N-day windows (N = selectedTeamIds.count * 2)
|
||||
// 3. Filter to windows where each selected team has at least 1 home game
|
||||
// 4. Cap at 50 windows (sample if more exist)
|
||||
// 5. For each valid window, find routes using GameDAGRouter with team games as anchors
|
||||
// 6. Rank by shortest duration + minimal miles
|
||||
// 7. Return top 10 results
|
||||
//
|
||||
// Example:
|
||||
// User selects: Yankees, Red Sox, Phillies
|
||||
// Window duration: 3 * 2 = 6 days
|
||||
// Valid windows: Any 6-day span where all 3 teams have a home game
|
||||
// Output: Top 10 trip options, ranked by efficiency
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
|
||||
/// Scenario E: Team-First planning
|
||||
/// Input: selectedTeamIds, sports (for fetching games)
|
||||
/// Output: Top 10 trip windows with routes visiting all selected teams' home stadiums
|
||||
///
|
||||
/// - Expected Behavior:
|
||||
/// - No selected teams → returns .failure with .missingTeamSelection
|
||||
/// - Fewer than 2 teams → returns .failure with .missingTeamSelection
|
||||
/// - No home games found → returns .failure with .noGamesInRange
|
||||
/// - No valid windows (no overlap) → returns .failure with .noValidRoutes
|
||||
/// - Each returned route visits ALL selected teams' home stadiums
|
||||
/// - Routes ranked by duration + miles (shorter is better)
|
||||
/// - Returns maximum of 10 options
|
||||
///
|
||||
/// - Invariants:
|
||||
/// - All routes contain at least one home game per selected team
|
||||
/// - Window duration = selectedTeamIds.count * 2 days
|
||||
/// - Maximum 50 windows evaluated (sampled if more exist)
|
||||
/// - Maximum 10 results returned
|
||||
///
|
||||
final class ScenarioEPlanner: ScenarioPlanner {
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
/// Maximum number of windows to evaluate (to prevent combinatorial explosion)
|
||||
private let maxWindowsToEvaluate = 50
|
||||
|
||||
/// Maximum number of results to return
|
||||
private let maxResultsToReturn = 10
|
||||
|
||||
// MARK: - ScenarioPlanner Protocol
|
||||
|
||||
func plan(request: PlanningRequest) -> ItineraryResult {
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Step 1: Validate team selection
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
let selectedTeamIds = request.preferences.selectedTeamIds
|
||||
|
||||
if selectedTeamIds.count < 2 {
|
||||
return .failure(
|
||||
PlanningFailure(
|
||||
reason: .missingTeamSelection,
|
||||
violations: [
|
||||
ConstraintViolation(
|
||||
type: .general,
|
||||
description: "Team-First mode requires at least 2 teams selected",
|
||||
severity: .error
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Step 2: Collect all HOME games for selected teams
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// For Team-First mode, we only care about home games because
|
||||
// the user wants to visit each team's home stadium.
|
||||
var homeGamesByTeam: [String: [Game]] = [:]
|
||||
var allHomeGames: [Game] = []
|
||||
|
||||
for game in request.allGames {
|
||||
if selectedTeamIds.contains(game.homeTeamId) {
|
||||
homeGamesByTeam[game.homeTeamId, default: []].append(game)
|
||||
allHomeGames.append(game)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify each team has at least one home game
|
||||
for teamId in selectedTeamIds {
|
||||
if homeGamesByTeam[teamId]?.isEmpty ?? true {
|
||||
let teamName = request.teams[teamId]?.fullName ?? teamId
|
||||
return .failure(
|
||||
PlanningFailure(
|
||||
reason: .noGamesInRange,
|
||||
violations: [
|
||||
ConstraintViolation(
|
||||
type: .selectedGames,
|
||||
description: "No home games found for \(teamName)",
|
||||
severity: .error
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
print("🔍 ScenarioE Step 2: Found \(allHomeGames.count) total home games across \(selectedTeamIds.count) teams")
|
||||
for (teamId, games) in homeGamesByTeam {
|
||||
let teamName = request.teams[teamId]?.fullName ?? teamId
|
||||
print("🔍 \(teamName): \(games.count) home games")
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Step 3: Generate sliding windows across the season
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
let windowDuration = request.preferences.teamFirstMaxDays
|
||||
print("🔍 ScenarioE Step 3: Window duration = \(windowDuration) days")
|
||||
|
||||
let validWindows = generateValidWindows(
|
||||
homeGamesByTeam: homeGamesByTeam,
|
||||
windowDurationDays: windowDuration,
|
||||
selectedTeamIds: selectedTeamIds
|
||||
)
|
||||
|
||||
if validWindows.isEmpty {
|
||||
return .failure(
|
||||
PlanningFailure(
|
||||
reason: .noValidRoutes,
|
||||
violations: [
|
||||
ConstraintViolation(
|
||||
type: .dateRange,
|
||||
description: "No \(windowDuration)-day windows found where all selected teams have home games",
|
||||
severity: .error
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
print("🔍 ScenarioE Step 3: Found \(validWindows.count) valid windows")
|
||||
|
||||
// Sample windows if too many exist
|
||||
let windowsToEvaluate: [DateInterval]
|
||||
if validWindows.count > maxWindowsToEvaluate {
|
||||
windowsToEvaluate = sampleWindows(validWindows, count: maxWindowsToEvaluate)
|
||||
print("🔍 ScenarioE Step 3: Sampled down to \(windowsToEvaluate.count) windows")
|
||||
} else {
|
||||
windowsToEvaluate = validWindows
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Step 4: For each valid window, find routes
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
var allItineraryOptions: [ItineraryOption] = []
|
||||
|
||||
for (windowIndex, window) in windowsToEvaluate.enumerated() {
|
||||
// Collect games in this window
|
||||
// Use one home game per team as anchors (the best one for route efficiency)
|
||||
var gamesInWindow: [Game] = []
|
||||
var anchorGameIds = Set<String>()
|
||||
|
||||
for teamId in selectedTeamIds {
|
||||
guard let teamGames = homeGamesByTeam[teamId] else { continue }
|
||||
|
||||
// Get all home games for this team in the window
|
||||
let teamGamesInWindow = teamGames.filter { window.contains($0.startTime) }
|
||||
|
||||
if teamGamesInWindow.isEmpty {
|
||||
// Window doesn't have a game for this team - skip this window
|
||||
// This shouldn't happen since we pre-filtered windows
|
||||
continue
|
||||
}
|
||||
|
||||
// Add all games to the pool
|
||||
gamesInWindow.append(contentsOf: teamGamesInWindow)
|
||||
|
||||
// Mark the earliest game as anchor (must visit this team)
|
||||
if let earliestGame = teamGamesInWindow.sorted(by: { $0.startTime < $1.startTime }).first {
|
||||
anchorGameIds.insert(earliestGame.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Skip if we don't have anchors for all teams
|
||||
guard anchorGameIds.count == selectedTeamIds.count else { continue }
|
||||
|
||||
// Remove duplicate games (same game could be added multiple times if team plays multiple home games)
|
||||
let uniqueGames = Array(Set(gamesInWindow)).sorted { $0.startTime < $1.startTime }
|
||||
|
||||
// Find routes using GameDAGRouter with anchor games
|
||||
let validRoutes = GameDAGRouter.findAllSensibleRoutes(
|
||||
from: uniqueGames,
|
||||
stadiums: request.stadiums,
|
||||
anchorGameIds: anchorGameIds,
|
||||
allowRepeatCities: request.preferences.allowRepeatCities,
|
||||
stopBuilder: buildStops
|
||||
)
|
||||
|
||||
// Build itineraries for valid routes
|
||||
for routeGames in validRoutes {
|
||||
let stops = buildStops(from: routeGames, stadiums: request.stadiums)
|
||||
guard !stops.isEmpty else { continue }
|
||||
|
||||
// Use shared ItineraryBuilder with arrival time validator
|
||||
guard let itinerary = ItineraryBuilder.build(
|
||||
stops: stops,
|
||||
constraints: request.drivingConstraints,
|
||||
logPrefix: "[ScenarioE]",
|
||||
segmentValidator: ItineraryBuilder.arrivalBeforeGameStart()
|
||||
) else {
|
||||
continue
|
||||
}
|
||||
|
||||
// Format window dates for rationale
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateFormat = "MMM d"
|
||||
let windowDesc = "\(dateFormatter.string(from: window.start)) - \(dateFormatter.string(from: window.end))"
|
||||
|
||||
let teamsVisited = routeGames.compactMap { game -> String? in
|
||||
if anchorGameIds.contains(game.id) {
|
||||
return request.teams[game.homeTeamId]?.abbreviation ?? game.homeTeamId
|
||||
}
|
||||
return nil
|
||||
}.joined(separator: ", ")
|
||||
|
||||
let cities = stops.map { $0.city }.joined(separator: " -> ")
|
||||
|
||||
let option = ItineraryOption(
|
||||
rank: 0, // Will re-rank later
|
||||
stops: itinerary.stops,
|
||||
travelSegments: itinerary.travelSegments,
|
||||
totalDrivingHours: itinerary.totalDrivingHours,
|
||||
totalDistanceMiles: itinerary.totalDistanceMiles,
|
||||
geographicRationale: "\(windowDesc): \(teamsVisited) - \(cities)"
|
||||
)
|
||||
allItineraryOptions.append(option)
|
||||
}
|
||||
|
||||
// Early exit if we have enough options
|
||||
if allItineraryOptions.count >= maxResultsToReturn * 5 {
|
||||
print("🔍 ScenarioE: Early exit at window \(windowIndex + 1) with \(allItineraryOptions.count) options")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Step 5: Rank and return top results
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
if allItineraryOptions.isEmpty {
|
||||
return .failure(
|
||||
PlanningFailure(
|
||||
reason: .constraintsUnsatisfiable,
|
||||
violations: [
|
||||
ConstraintViolation(
|
||||
type: .geographicSanity,
|
||||
description: "No routes found that can visit all selected teams within driving constraints",
|
||||
severity: .error
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Deduplicate options (same stops in same order)
|
||||
let uniqueOptions = deduplicateOptions(allItineraryOptions)
|
||||
|
||||
// Sort by: shortest duration first, then fewest miles
|
||||
let sortedOptions = uniqueOptions.sorted { optionA, optionB in
|
||||
// Calculate trip duration in days
|
||||
let daysA = tripDurationDays(for: optionA)
|
||||
let daysB = tripDurationDays(for: optionB)
|
||||
|
||||
if daysA != daysB {
|
||||
return daysA < daysB // Shorter trips first
|
||||
}
|
||||
return optionA.totalDistanceMiles < optionB.totalDistanceMiles // Fewer miles second
|
||||
}
|
||||
|
||||
// Take top N and re-rank
|
||||
let topOptions = Array(sortedOptions.prefix(maxResultsToReturn))
|
||||
let rankedOptions = topOptions.enumerated().map { index, option in
|
||||
ItineraryOption(
|
||||
rank: index + 1,
|
||||
stops: option.stops,
|
||||
travelSegments: option.travelSegments,
|
||||
totalDrivingHours: option.totalDrivingHours,
|
||||
totalDistanceMiles: option.totalDistanceMiles,
|
||||
geographicRationale: option.geographicRationale
|
||||
)
|
||||
}
|
||||
|
||||
print("🔍 ScenarioE: Returning \(rankedOptions.count) options")
|
||||
|
||||
return .success(rankedOptions)
|
||||
}
|
||||
|
||||
// MARK: - Window Generation
|
||||
|
||||
/// Generates all valid N-day windows across the season where ALL selected teams have home games.
|
||||
///
|
||||
/// Algorithm:
|
||||
/// 1. Find the overall date range (earliest to latest game across all teams)
|
||||
/// 2. Slide a window across this range, one day at a time
|
||||
/// 3. Keep windows where each team has at least one home game
|
||||
///
|
||||
private func generateValidWindows(
|
||||
homeGamesByTeam: [String: [Game]],
|
||||
windowDurationDays: Int,
|
||||
selectedTeamIds: Set<String>
|
||||
) -> [DateInterval] {
|
||||
|
||||
// Find overall date range
|
||||
let allGames = homeGamesByTeam.values.flatMap { $0 }
|
||||
guard let earliest = allGames.map({ $0.startTime }).min(),
|
||||
let latest = allGames.map({ $0.startTime }).max() else {
|
||||
return []
|
||||
}
|
||||
|
||||
let calendar = Calendar.current
|
||||
let earliestDay = calendar.startOfDay(for: earliest)
|
||||
let latestDay = calendar.startOfDay(for: latest)
|
||||
|
||||
// Generate sliding windows
|
||||
var validWindows: [DateInterval] = []
|
||||
var currentStart = earliestDay
|
||||
|
||||
while let windowEnd = calendar.date(byAdding: .day, value: windowDurationDays, to: currentStart),
|
||||
windowEnd <= calendar.date(byAdding: .day, value: 1, to: latestDay)! {
|
||||
|
||||
let window = DateInterval(start: currentStart, end: windowEnd)
|
||||
|
||||
// Check if all teams have at least one home game in this window
|
||||
let allTeamsHaveGame = selectedTeamIds.allSatisfy { teamId in
|
||||
guard let teamGames = homeGamesByTeam[teamId] else { return false }
|
||||
return teamGames.contains { window.contains($0.startTime) }
|
||||
}
|
||||
|
||||
if allTeamsHaveGame {
|
||||
validWindows.append(window)
|
||||
}
|
||||
|
||||
// Slide window by one day
|
||||
guard let nextStart = calendar.date(byAdding: .day, value: 1, to: currentStart) else {
|
||||
break
|
||||
}
|
||||
currentStart = nextStart
|
||||
}
|
||||
|
||||
return validWindows
|
||||
}
|
||||
|
||||
/// Samples windows evenly across the season to avoid clustering.
|
||||
private func sampleWindows(_ windows: [DateInterval], count: Int) -> [DateInterval] {
|
||||
guard windows.count > count else { return windows }
|
||||
|
||||
// Take evenly spaced samples
|
||||
var sampled: [DateInterval] = []
|
||||
let step = Double(windows.count) / Double(count)
|
||||
|
||||
for i in 0..<count {
|
||||
let index = Int(Double(i) * step)
|
||||
if index < windows.count {
|
||||
sampled.append(windows[index])
|
||||
}
|
||||
}
|
||||
|
||||
return sampled
|
||||
}
|
||||
|
||||
// MARK: - Stop Building
|
||||
|
||||
/// Converts a list of games into itinerary stops.
|
||||
/// Groups consecutive games at the same stadium into one stop.
|
||||
private func buildStops(
|
||||
from games: [Game],
|
||||
stadiums: [String: Stadium]
|
||||
) -> [ItineraryStop] {
|
||||
guard !games.isEmpty else { return [] }
|
||||
|
||||
// Sort games chronologically
|
||||
let sortedGames = games.sorted { $0.startTime < $1.startTime }
|
||||
|
||||
// Group consecutive games at the same stadium
|
||||
var stops: [ItineraryStop] = []
|
||||
var currentStadiumId: String? = nil
|
||||
var currentGames: [Game] = []
|
||||
|
||||
for game in sortedGames {
|
||||
if game.stadiumId == currentStadiumId {
|
||||
// Same stadium as previous game - add to current group
|
||||
currentGames.append(game)
|
||||
} else {
|
||||
// Different stadium - finalize previous stop (if any) and start new one
|
||||
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: String,
|
||||
stadiums: [String: 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
|
||||
)
|
||||
|
||||
// departureDate is same day as last game
|
||||
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: - Deduplication
|
||||
|
||||
/// Removes duplicate itinerary options (same stops in same order).
|
||||
private func deduplicateOptions(_ options: [ItineraryOption]) -> [ItineraryOption] {
|
||||
var seen = Set<String>()
|
||||
var unique: [ItineraryOption] = []
|
||||
|
||||
for option in options {
|
||||
// Create key from stop cities in order
|
||||
let key = option.stops.map { $0.city }.joined(separator: "-")
|
||||
if !seen.contains(key) {
|
||||
seen.insert(key)
|
||||
unique.append(option)
|
||||
}
|
||||
}
|
||||
|
||||
return unique
|
||||
}
|
||||
|
||||
// MARK: - Trip Duration Calculation
|
||||
|
||||
/// Calculates trip duration in days for an itinerary option.
|
||||
private func tripDurationDays(for option: ItineraryOption) -> Int {
|
||||
guard let firstStop = option.stops.first,
|
||||
let lastStop = option.stops.last else {
|
||||
return 0
|
||||
}
|
||||
|
||||
let calendar = Calendar.current
|
||||
let days = calendar.dateComponents(
|
||||
[.day],
|
||||
from: calendar.startOfDay(for: firstStop.arrivalDate),
|
||||
to: calendar.startOfDay(for: lastStop.departureDate)
|
||||
).day ?? 0
|
||||
|
||||
return max(1, days + 1)
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
import Foundation
|
||||
|
||||
/// Protocol that all scenario planners must implement.
|
||||
/// Each scenario (A, B, C, D) has its own isolated implementation.
|
||||
/// Each scenario (A, B, C, D, E) has its own isolated implementation.
|
||||
///
|
||||
/// - Invariants:
|
||||
/// - Always returns either success or explicit failure, never throws
|
||||
@@ -25,28 +25,39 @@ protocol ScenarioPlanner {
|
||||
/// Factory for creating the appropriate scenario planner.
|
||||
///
|
||||
/// - Expected Behavior:
|
||||
/// - planningMode == .teamFirst with >= 2 teams → ScenarioEPlanner
|
||||
/// - followTeamId != nil → ScenarioDPlanner
|
||||
/// - selectedGames not empty → ScenarioBPlanner
|
||||
/// - startLocation AND endLocation != nil → ScenarioCPlanner
|
||||
/// - Otherwise → ScenarioAPlanner (default)
|
||||
///
|
||||
/// Priority order: D > B > C > A (first matching wins)
|
||||
/// Priority order: E > D > B > C > A (first matching wins)
|
||||
enum ScenarioPlannerFactory {
|
||||
|
||||
/// Creates the appropriate planner based on the request inputs.
|
||||
///
|
||||
/// - Expected Behavior:
|
||||
/// - planningMode == .teamFirst with >= 2 teams → ScenarioEPlanner
|
||||
/// - followTeamId set → ScenarioDPlanner
|
||||
/// - selectedGames not empty → ScenarioBPlanner
|
||||
/// - Both start and end locations → ScenarioCPlanner
|
||||
/// - Otherwise → ScenarioAPlanner
|
||||
static func planner(for request: PlanningRequest) -> ScenarioPlanner {
|
||||
print("🔍 ScenarioPlannerFactory: Selecting planner...")
|
||||
print(" - planningMode: \(request.preferences.planningMode)")
|
||||
print(" - selectedTeamIds.count: \(request.preferences.selectedTeamIds.count)")
|
||||
print(" - followTeamId: \(request.preferences.followTeamId ?? "nil")")
|
||||
print(" - selectedGames.count: \(request.selectedGames.count)")
|
||||
print(" - startLocation: \(request.startLocation?.name ?? "nil")")
|
||||
print(" - endLocation: \(request.endLocation?.name ?? "nil")")
|
||||
|
||||
// Scenario E: Team-First mode - user selects teams, finds optimal trip windows
|
||||
if request.preferences.planningMode == .teamFirst &&
|
||||
request.preferences.selectedTeamIds.count >= 2 {
|
||||
print("🔍 ScenarioPlannerFactory: → ScenarioEPlanner (team-first)")
|
||||
return ScenarioEPlanner()
|
||||
}
|
||||
|
||||
// Scenario D: User wants to follow a specific team
|
||||
if request.preferences.followTeamId != nil {
|
||||
print("🔍 ScenarioPlannerFactory: → ScenarioDPlanner (follow team)")
|
||||
@@ -73,11 +84,16 @@ enum ScenarioPlannerFactory {
|
||||
/// Classifies which scenario applies to this request.
|
||||
///
|
||||
/// - Expected Behavior:
|
||||
/// - planningMode == .teamFirst with >= 2 teams → .scenarioE
|
||||
/// - followTeamId set → .scenarioD
|
||||
/// - selectedGames not empty → .scenarioB
|
||||
/// - Both start and end locations → .scenarioC
|
||||
/// - Otherwise → .scenarioA
|
||||
static func classify(_ request: PlanningRequest) -> PlanningScenario {
|
||||
if request.preferences.planningMode == .teamFirst &&
|
||||
request.preferences.selectedTeamIds.count >= 2 {
|
||||
return .scenarioE
|
||||
}
|
||||
if request.preferences.followTeamId != nil {
|
||||
return .scenarioD
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user