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:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -28,7 +28,6 @@ struct SportsIconImageGenerator {
|
||||
"figure.soccer",
|
||||
// Venues/events
|
||||
"sportscourt.fill",
|
||||
"stadium.fill",
|
||||
"trophy.fill",
|
||||
"ticket.fill",
|
||||
// Travel/navigation
|
||||
|
||||
@@ -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
|
||||
|
||||
189
SportsTime/Features/Trip/Views/TeamPickerView.swift
Normal file
189
SportsTime/Features/Trip/Views/TeamPickerView.swift
Normal 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()
|
||||
}
|
||||
@@ -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: {}
|
||||
)
|
||||
|
||||
347
SportsTime/Features/Trip/Views/Wizard/TeamFirstWizardStep.swift
Normal file
347
SportsTime/Features/Trip/Views/Wizard/TeamFirstWizardStep.swift
Normal 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()
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user