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:
Trey t
2026-01-26 18:13:12 -06:00
parent bfa172de38
commit dbb0099776
129 changed files with 14805 additions and 25325 deletions

View File

@@ -28,6 +28,7 @@ enum PlanningMode: String, Codable, CaseIterable, Identifiable {
case gameFirst // Pick games first, trip around those games
case locations // Start/end locations, optional games along route
case followTeam // Follow one team's schedule (home + away)
case teamFirst // Select teams, find optimal trip windows across season
var id: String { rawValue }
@@ -37,6 +38,7 @@ enum PlanningMode: String, Codable, CaseIterable, Identifiable {
case .gameFirst: return "By Games"
case .locations: return "By Route"
case .followTeam: return "Follow Team"
case .teamFirst: return "By Teams"
}
}
@@ -46,6 +48,7 @@ enum PlanningMode: String, Codable, CaseIterable, Identifiable {
case .gameFirst: return "Build trip around specific games"
case .locations: return "Plan route between locations"
case .followTeam: return "Follow your team on the road"
case .teamFirst: return "Select teams, find best trip windows"
}
}
@@ -55,6 +58,7 @@ enum PlanningMode: String, Codable, CaseIterable, Identifiable {
case .gameFirst: return "sportscourt"
case .locations: return "map"
case .followTeam: return "person.3.fill"
case .teamFirst: return "person.2.badge.gearshape"
}
}
}
@@ -258,6 +262,9 @@ struct TripPreferences: Codable, Hashable {
/// Trip duration for gameFirst mode sliding windows (number of days)
var gameFirstTripDuration: Int
/// Teams to visit (for Team-First mode) - canonical team IDs
var selectedTeamIds: Set<String> = []
init(
planningMode: PlanningMode = .dateRange,
startLocation: LocationInput? = nil,
@@ -281,7 +288,8 @@ struct TripPreferences: Codable, Hashable {
selectedRegions: Set<Region> = [.east, .central, .west],
followTeamId: String? = nil,
useHomeLocation: Bool = true,
gameFirstTripDuration: Int = 7
gameFirstTripDuration: Int = 7,
selectedTeamIds: Set<String> = []
) {
self.planningMode = planningMode
self.startLocation = startLocation
@@ -306,6 +314,7 @@ struct TripPreferences: Codable, Hashable {
self.followTeamId = followTeamId
self.useHomeLocation = useHomeLocation
self.gameFirstTripDuration = gameFirstTripDuration
self.selectedTeamIds = selectedTeamIds
}
var totalDriverHoursPerDay: Double {
@@ -318,4 +327,9 @@ struct TripPreferences: Codable, Hashable {
let days = Calendar.current.dateComponents([.day], from: startDate, to: endDate).day ?? 7
return max(1, days)
}
/// Maximum trip duration for Team-First mode (2 days per selected team)
var teamFirstMaxDays: Int {
selectedTeamIds.count * 2
}
}

View File

@@ -122,7 +122,6 @@ struct AnimatedSportsIcon: View {
(0.9, 0.85, "mappin.circle.fill", -8, 0.75),
(0.5, 0.03, "car.fill", 0, 0.7),
(0.5, 0.97, "map.fill", 3, 0.75),
(0.25, 0.93, "stadium.fill", -5, 0.7),
(0.75, 0.95, "flag.checkered", 7, 0.7),
// Middle area icons (will appear behind cards)
(0.35, 0.22, "tennisball.fill", -8, 0.65),

View File

@@ -28,7 +28,6 @@ struct SportsIconImageGenerator {
"figure.soccer",
// Venues/events
"sportscourt.fill",
"stadium.fill",
"trophy.fill",
"ticket.fill",
// Travel/navigation

View File

@@ -65,6 +65,11 @@ final class TripWizardViewModel {
var startLocation: LocationInput? = nil
var endLocation: LocationInput? = nil
// MARK: - Mode-Specific: teamFirst
var teamFirstSport: Sport? = nil
var teamFirstSelectedTeamIds: Set<String> = []
// MARK: - Planning State
var isPlanning: Bool = false
@@ -106,6 +111,10 @@ final class TripWizardViewModel {
planningMode == .locations
}
var showTeamFirstStep: Bool {
planningMode == .teamFirst
}
// MARK: - Validation
/// All required fields must be set before planning
@@ -124,6 +133,8 @@ final class TripWizardViewModel {
return startLocation != nil && endLocation != nil && hasSetDates && !selectedSports.isEmpty
case .followTeam:
return selectedTeamId != nil && hasSetDates
case .teamFirst:
return teamFirstSport != nil && teamFirstSelectedTeamIds.count >= 2
}
}
@@ -139,7 +150,9 @@ final class TripWizardViewModel {
selectedGames: selectedGameIds.isEmpty ? .missing : .valid,
selectedTeam: selectedTeamId == nil ? .missing : .valid,
startLocation: startLocation == nil ? .missing : .valid,
endLocation: endLocation == nil ? .missing : .valid
endLocation: endLocation == nil ? .missing : .valid,
teamFirstTeams: teamFirstSelectedTeamIds.count >= 2 ? .valid : .missing,
teamFirstTeamCount: teamFirstSelectedTeamIds.count
)
}
@@ -198,6 +211,10 @@ final class TripWizardViewModel {
// locations mode fields
startLocation = nil
endLocation = nil
// teamFirst mode fields
teamFirstSport = nil
teamFirstSelectedTeamIds = []
}
}
@@ -223,6 +240,8 @@ struct FieldValidation {
let selectedTeam: Status
let startLocation: Status
let endLocation: Status
let teamFirstTeams: Status
let teamFirstTeamCount: Int
/// Returns only the fields that are required for the current planning mode
var requiredFields: [(name: String, status: Status)] {
@@ -261,6 +280,12 @@ struct FieldValidation {
("Route Preference", routePreference),
("Repeat Cities", repeatCities)
]
case .teamFirst:
fields = [
("Teams", teamFirstTeams),
("Route Preference", routePreference),
("Repeat Cities", repeatCities)
]
}
return fields

View File

@@ -0,0 +1,189 @@
//
// TeamPickerView.swift
// SportsTime
//
// Multi-select team picker grid for Team-First planning mode.
// Users select multiple teams (>=2) and the app finds optimal trip windows.
//
import SwiftUI
struct TeamPickerView: View {
@Environment(\.colorScheme) private var colorScheme
let sport: Sport
@Binding var selectedTeamIds: Set<String>
@State private var searchText = ""
private let columns = [
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible())
]
private var teams: [Team] {
let allTeams = AppDataProvider.shared.teams
.filter { $0.sport == sport }
.sorted { $0.fullName < $1.fullName }
if searchText.isEmpty {
return allTeams
}
return allTeams.filter {
$0.fullName.localizedCaseInsensitiveContains(searchText) ||
$0.city.localizedCaseInsensitiveContains(searchText) ||
$0.abbreviation.localizedCaseInsensitiveContains(searchText)
}
}
var body: some View {
VStack(spacing: Theme.Spacing.md) {
// Search bar
HStack {
Image(systemName: "magnifyingglass")
.foregroundStyle(Theme.textMuted(colorScheme))
TextField("Search teams...", text: $searchText)
.textFieldStyle(.plain)
if !searchText.isEmpty {
Button {
searchText = ""
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(Theme.textMuted(colorScheme))
}
}
}
.padding(Theme.Spacing.sm)
.background(Theme.cardBackgroundElevated(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
// Selection count badge
if !selectedTeamIds.isEmpty {
HStack {
Text("\(selectedTeamIds.count) team\(selectedTeamIds.count == 1 ? "" : "s") selected")
.font(.caption)
.fontWeight(.medium)
.foregroundStyle(.white)
.padding(.horizontal, Theme.Spacing.sm)
.padding(.vertical, Theme.Spacing.xs)
.background(Theme.warmOrange)
.clipShape(Capsule())
Spacer()
Button("Clear all") {
withAnimation(Theme.Animation.spring) {
selectedTeamIds.removeAll()
}
}
.font(.caption)
.foregroundStyle(Theme.warmOrange)
}
}
// Team grid
ScrollView {
LazyVGrid(columns: columns, spacing: Theme.Spacing.sm) {
ForEach(teams) { team in
TeamCard(
team: team,
isSelected: selectedTeamIds.contains(team.id),
onTap: { toggleTeam(team) }
)
}
}
.padding(.bottom, Theme.Spacing.lg)
}
}
}
private func toggleTeam(_ team: Team) {
withAnimation(Theme.Animation.spring) {
if selectedTeamIds.contains(team.id) {
selectedTeamIds.remove(team.id)
} else {
selectedTeamIds.insert(team.id)
}
}
}
}
// MARK: - Team Card
private struct TeamCard: View {
@Environment(\.colorScheme) private var colorScheme
let team: Team
let isSelected: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
VStack(spacing: Theme.Spacing.xs) {
// Team color circle with checkmark overlay
ZStack {
Circle()
.fill(teamColor)
.frame(width: 44, height: 44)
if isSelected {
Circle()
.fill(Color.black.opacity(0.3))
.frame(width: 44, height: 44)
Image(systemName: "checkmark")
.font(.title3)
.fontWeight(.bold)
.foregroundStyle(.white)
}
}
// Team name
Text(team.name)
.font(.caption)
.fontWeight(isSelected ? .semibold : .regular)
.foregroundStyle(isSelected ? Theme.warmOrange : Theme.textPrimary(colorScheme))
.lineLimit(1)
.minimumScaleFactor(0.8)
// City
Text(team.city)
.font(.caption2)
.foregroundStyle(Theme.textMuted(colorScheme))
.lineLimit(1)
}
.frame(maxWidth: .infinity)
.padding(.vertical, Theme.Spacing.sm)
.background(isSelected ? Theme.warmOrange.opacity(0.15) : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
.contentShape(Rectangle())
.overlay(
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
.stroke(isSelected ? Theme.warmOrange : Theme.textMuted(colorScheme).opacity(0.3), lineWidth: isSelected ? 2 : 1)
)
}
.buttonStyle(.plain)
}
private var teamColor: Color {
if let hex = team.primaryColor {
return Color(hex: hex)
}
return team.sport.themeColor
}
}
// MARK: - Preview
#Preview {
TeamPickerView(
sport: .mlb,
selectedTeamIds: .constant(["team_mlb_bos", "team_mlb_nyy"])
)
.padding()
.themedBackground()
}

View File

@@ -27,6 +27,7 @@ struct ReviewStep: View {
var selectedTeamName: String? = nil
var startLocationName: String? = nil
var endLocationName: String? = nil
var teamFirstTeamCount: Int = 0
var body: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.md) {
@@ -114,6 +115,8 @@ struct ReviewStep: View {
return startLocationName ?? "Not selected"
case "End Location":
return endLocationName ?? "Not selected"
case "Teams":
return teamFirstTeamCount >= 2 ? "\(teamFirstTeamCount) teams selected" : "Select at least 2 teams"
default:
return ""
}
@@ -180,7 +183,9 @@ private struct ReviewRow: View {
selectedGames: .valid,
selectedTeam: .valid,
startLocation: .valid,
endLocation: .valid
endLocation: .valid,
teamFirstTeams: .valid,
teamFirstTeamCount: 0
),
onPlan: {}
)
@@ -210,7 +215,9 @@ private struct ReviewRow: View {
selectedGames: .valid,
selectedTeam: .valid,
startLocation: .valid,
endLocation: .valid
endLocation: .valid,
teamFirstTeams: .valid,
teamFirstTeamCount: 0
),
onPlan: {}
)

View File

@@ -0,0 +1,347 @@
//
// TeamFirstWizardStep.swift
// SportsTime
//
// Wizard step for Team-First planning mode.
// Uses sheet-based drill-down matching TeamPickerStep: Sport Teams (multi-select).
//
import SwiftUI
struct TeamFirstWizardStep: View {
@Environment(\.colorScheme) private var colorScheme
@Binding var selectedSport: Sport?
@Binding var selectedTeamIds: Set<String>
@State private var showTeamPicker = false
private var selectedTeams: [Team] {
selectedTeamIds.compactMap { teamId in
AppDataProvider.shared.teams.first { $0.id == teamId }
}.sorted { $0.fullName < $1.fullName }
}
private var isValid: Bool {
selectedTeamIds.count >= 2
}
var body: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.md) {
StepHeader(
title: "Which teams do you want to see?",
subtitle: "Select 2 or more teams to find optimal trip windows"
)
// Selection button
Button {
showTeamPicker = true
} label: {
HStack {
if !selectedTeams.isEmpty {
// Show selected teams
teamPreview
Spacer()
Button {
selectedTeamIds.removeAll()
selectedSport = nil
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(Theme.textMuted(colorScheme))
}
} else {
// Empty state
Image(systemName: "person.2.fill")
.foregroundStyle(Theme.warmOrange)
Text("Select teams")
.font(.subheadline)
.foregroundStyle(Theme.textMuted(colorScheme))
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
}
}
.padding(Theme.Spacing.md)
.background(Theme.cardBackgroundElevated(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
.overlay(
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
.stroke(isValid ? Theme.warmOrange : Theme.textMuted(colorScheme).opacity(0.3), lineWidth: isValid ? 2 : 1)
)
}
.buttonStyle(.plain)
// Validation message
if selectedTeamIds.isEmpty {
validationLabel(text: "Select at least 2 teams", isValid: false)
} else if selectedTeamIds.count == 1 {
validationLabel(text: "Select 1 more team (minimum 2)", isValid: false)
} else {
validationLabel(text: "Ready to find trips for \(selectedTeamIds.count) teams", isValid: true)
}
}
.padding(Theme.Spacing.lg)
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
.overlay {
RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
}
.sheet(isPresented: $showTeamPicker) {
TeamFirstPickerSheet(
selectedSport: $selectedSport,
selectedTeamIds: $selectedTeamIds
)
}
}
// MARK: - Team Preview
@ViewBuilder
private var teamPreview: some View {
if selectedTeams.count <= 3 {
// Show individual teams
HStack(spacing: Theme.Spacing.sm) {
ForEach(selectedTeams.prefix(3)) { team in
HStack(spacing: Theme.Spacing.xs) {
Circle()
.fill(team.primaryColor.map { Color(hex: $0) } ?? team.sport.themeColor)
.frame(width: 20, height: 20)
Text(team.abbreviation)
.font(.caption)
.fontWeight(.medium)
.foregroundStyle(Theme.textPrimary(colorScheme))
}
.padding(.horizontal, Theme.Spacing.sm)
.padding(.vertical, Theme.Spacing.xs)
.background(Theme.cardBackground(colorScheme))
.clipShape(Capsule())
}
}
} else {
// Show count with first few team colors
HStack(spacing: -8) {
ForEach(Array(selectedTeams.prefix(4).enumerated()), id: \.element.id) { index, team in
Circle()
.fill(team.primaryColor.map { Color(hex: $0) } ?? team.sport.themeColor)
.frame(width: 24, height: 24)
.overlay(
Circle()
.stroke(Theme.cardBackgroundElevated(colorScheme), lineWidth: 2)
)
.zIndex(Double(4 - index))
}
}
Text("\(selectedTeamIds.count) teams")
.font(.subheadline)
.fontWeight(.medium)
.foregroundStyle(Theme.textPrimary(colorScheme))
}
}
private func validationLabel(text: String, isValid: Bool) -> some View {
HStack(spacing: Theme.Spacing.xs) {
Image(systemName: isValid ? "checkmark.circle.fill" : "info.circle.fill")
.foregroundStyle(isValid ? .green : Theme.warmOrange)
Text(text)
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
Spacer()
}
}
}
// MARK: - Team First Picker Sheet
private struct TeamFirstPickerSheet: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var colorScheme
@Binding var selectedSport: Sport?
@Binding var selectedTeamIds: Set<String>
var body: some View {
NavigationStack {
List {
ForEach(Sport.supported, id: \.self) { sport in
NavigationLink {
TeamMultiSelectListView(
sport: sport,
selectedTeamIds: $selectedTeamIds,
onDone: {
selectedSport = sport
dismiss()
}
)
} label: {
HStack(spacing: Theme.Spacing.sm) {
Image(systemName: sport.iconName)
.font(.title2)
.foregroundStyle(sport.themeColor)
.frame(width: 32)
Text(sport.rawValue)
.font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme))
Spacer()
Text("\(teamsCount(for: sport)) teams")
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
}
.padding(.vertical, Theme.Spacing.xs)
}
}
}
.listStyle(.plain)
.navigationTitle("Select Sport")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
}
}
.presentationDetents([.large])
}
private func teamsCount(for sport: Sport) -> Int {
AppDataProvider.shared.teams.filter { $0.sport == sport }.count
}
}
// MARK: - Team Multi-Select List View
private struct TeamMultiSelectListView: View {
@Environment(\.colorScheme) private var colorScheme
let sport: Sport
@Binding var selectedTeamIds: Set<String>
let onDone: () -> Void
@State private var searchText = ""
private var teams: [Team] {
let allTeams = AppDataProvider.shared.teams
.filter { $0.sport == sport }
.sorted { $0.fullName < $1.fullName }
if searchText.isEmpty {
return allTeams
}
return allTeams.filter {
$0.fullName.localizedCaseInsensitiveContains(searchText) ||
$0.city.localizedCaseInsensitiveContains(searchText)
}
}
private var selectionCount: Int {
// Count only teams from the current sport
let sportTeamIds = Set(AppDataProvider.shared.teams.filter { $0.sport == sport }.map { $0.id })
return selectedTeamIds.intersection(sportTeamIds).count
}
var body: some View {
List {
ForEach(teams) { team in
Button {
toggleTeam(team)
} label: {
HStack(spacing: Theme.Spacing.sm) {
Circle()
.fill(team.primaryColor.map { Color(hex: $0) } ?? sport.themeColor)
.frame(width: 28, height: 28)
VStack(alignment: .leading, spacing: 2) {
Text(team.fullName)
.font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme))
Text(team.city)
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
}
Spacer()
if selectedTeamIds.contains(team.id) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Theme.warmOrange)
} else {
Image(systemName: "circle")
.foregroundStyle(Theme.textMuted(colorScheme).opacity(0.5))
}
}
.padding(.vertical, Theme.Spacing.xs)
}
.buttonStyle(.plain)
}
}
.listStyle(.plain)
.searchable(text: $searchText, prompt: "Search teams")
.navigationTitle(sport.rawValue)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button {
onDone()
} label: {
if selectionCount >= 2 {
Text("Done (\(selectionCount))")
.fontWeight(.semibold)
} else {
Text("Done")
}
}
.disabled(selectionCount < 2)
}
}
.onAppear {
// Clear selections from other sports when entering
let sportTeamIds = Set(AppDataProvider.shared.teams.filter { $0.sport == sport }.map { $0.id })
selectedTeamIds = selectedTeamIds.intersection(sportTeamIds)
}
}
private func toggleTeam(_ team: Team) {
withAnimation(.easeInOut(duration: 0.15)) {
if selectedTeamIds.contains(team.id) {
selectedTeamIds.remove(team.id)
} else {
selectedTeamIds.insert(team.id)
}
}
}
}
// MARK: - Preview
#Preview("Empty") {
TeamFirstWizardStep(
selectedSport: .constant(nil),
selectedTeamIds: .constant([])
)
.padding()
.themedBackground()
}
#Preview("Teams Selected") {
TeamFirstWizardStep(
selectedSport: .constant(.mlb),
selectedTeamIds: .constant(["team_mlb_bos", "team_mlb_nyy", "team_mlb_chc"])
)
.padding()
.themedBackground()
}

View File

@@ -60,6 +60,13 @@ struct TripWizardView: View {
)
}
if viewModel.showTeamFirstStep {
TeamFirstWizardStep(
selectedSport: $viewModel.teamFirstSport,
selectedTeamIds: $viewModel.teamFirstSelectedTeamIds
)
}
// Common steps (conditionally shown)
if viewModel.showDatesStep {
DatesStep(
@@ -116,7 +123,8 @@ struct TripWizardView: View {
selectedGameCount: viewModel.selectedGameIds.count,
selectedTeamName: selectedTeamName,
startLocationName: viewModel.startLocation?.name,
endLocationName: viewModel.endLocation?.name
endLocationName: viewModel.endLocation?.name,
teamFirstTeamCount: viewModel.teamFirstSelectedTeamIds.count
)
}
.transition(.opacity)
@@ -167,7 +175,12 @@ struct TripWizardView: View {
// For gameFirst mode, use the UI-selected date range (set by GamePickerStep)
// The date range is a 7-day span centered on the selected game(s)
var games: [Game]
if viewModel.planningMode == .gameFirst && !viewModel.selectedGameIds.isEmpty {
if viewModel.planningMode == .teamFirst {
// Team-First mode: fetch ALL games for the season
// ScenarioEPlanner will generate sliding windows across the full season
games = try await AppDataProvider.shared.allGames(for: preferences.sports)
print("🔍 TripWizard: Team-First mode - fetched \(games.count) games for \(preferences.sports)")
} else if viewModel.planningMode == .gameFirst && !viewModel.selectedGameIds.isEmpty {
// Fetch all games for the selected sports within the UI date range
// GamePickerStep already set viewModel.startDate/endDate to a 7-day span
let allGames = try await AppDataProvider.shared.allGames(for: preferences.sports)
@@ -248,6 +261,8 @@ struct TripWizardView: View {
sports = viewModel.gamePickerSports
} else if viewModel.planningMode == .followTeam, let sport = viewModel.teamPickerSport {
sports = [sport]
} else if viewModel.planningMode == .teamFirst, let sport = viewModel.teamFirstSport {
sports = [sport]
} else {
sports = viewModel.selectedSports
}
@@ -264,7 +279,8 @@ struct TripWizardView: View {
routePreference: viewModel.routePreference,
allowRepeatCities: viewModel.allowRepeatCities,
selectedRegions: viewModel.selectedRegions,
followTeamId: viewModel.selectedTeamId
followTeamId: viewModel.selectedTeamId,
selectedTeamIds: viewModel.teamFirstSelectedTeamIds
)
}

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

View File

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

View File

@@ -17,11 +17,13 @@ import CoreLocation
/// - scenarioB: User selects specific games + date range
/// - scenarioC: User provides start/end locations, system plans route
/// - scenarioD: User follows a team's schedule
/// - scenarioE: User selects teams, system finds best trip windows across season
enum PlanningScenario: Equatable {
case scenarioA // Date range only
case scenarioB // Selected games + date range
case scenarioC // Start + end locations
case scenarioD // Follow team schedule
case scenarioE // Team-first (select teams, find trip windows)
}
// MARK: - Planning Failure
@@ -568,4 +570,25 @@ struct PlanningRequest {
func stadium(for game: Game) -> Stadium? {
stadiums[game.stadiumId]
}
// MARK: - Team-First Mode Properties
/// Teams selected for Team-First mode (resolved from selectedTeamIds)
@MainActor
var selectedTeams: [Team] {
preferences.selectedTeamIds.compactMap { teamId in
AppDataProvider.shared.team(for: teamId)
}
}
/// All home games for the selected teams (for Team-First mode)
/// Filters availableGames to only those where a selected team is the home team
var homeGamesForSelectedTeams: [Game] {
let selectedIds = preferences.selectedTeamIds
guard !selectedIds.isEmpty else { return [] }
return availableGames.filter { game in
selectedIds.contains(game.homeTeamId)
}
}
}