Files
Sportstime/SportsTime/Features/Trip/Views/Wizard/Steps/GamePickerStep.swift
Trey t d97dec44b2 fix(planning): gameFirst mode now uses full date range and shows correct month
Two bugs fixed in "By Games" trip planning mode:

1. Calendar navigation: DateRangePicker now navigates to the selected
   game's month when startDate changes externally, instead of staying
   on the current month.

2. Date range calculation: Fixed race condition where date range was
   calculated before games were loaded. Now updateDateRangeForSelectedGames()
   is called after loadSummaryGames() completes.

3. Bonus games: planTrip() now uses the UI-selected 7-day date range
   instead of overriding it with just the anchor game dates. This allows
   ScenarioBPlanner to find additional games within the trip window.

Added regression tests to verify gameFirst mode includes bonus games.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 16:37:19 -06:00

624 lines
24 KiB
Swift

//
// GamePickerStep.swift
// SportsTime
//
// Game selection step for "By Games" planning mode.
// Uses sheet-based drill-down: Sports Teams Games.
//
import SwiftUI
struct GamePickerStep: View {
@Environment(\.colorScheme) private var colorScheme
@Binding var selectedSports: Set<Sport>
@Binding var selectedTeamIds: Set<String>
@Binding var selectedGameIds: Set<String>
@Binding var startDate: Date
@Binding var endDate: Date
@State private var showSportsPicker = false
@State private var showTeamsPicker = false
@State private var showGamesPicker = false
var body: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.md) {
StepHeader(
title: "Select games for your trip",
subtitle: "Pick sports, then teams, then games"
)
// Step 1: Sports Selection
selectionRow(
icon: "sportscourt.fill",
label: "Sports",
value: selectedSports.isEmpty ? nil : selectedSports.map(\.rawValue).sorted().joined(separator: ", "),
placeholder: "Select sports",
onTap: { showSportsPicker = true },
onClear: {
selectedSports = []
selectedTeamIds = []
selectedGameIds = []
}
)
// Step 2: Teams Selection (enabled after sports)
selectionRow(
icon: "person.2.fill",
label: "Teams",
value: selectedTeamIds.isEmpty ? nil : "\(selectedTeamIds.count) team\(selectedTeamIds.count == 1 ? "" : "s")",
placeholder: "Select teams",
isEnabled: !selectedSports.isEmpty,
onTap: { showTeamsPicker = true },
onClear: {
selectedTeamIds = []
selectedGameIds = []
}
)
// Step 3: Games Selection (enabled after teams)
selectionRow(
icon: "ticket.fill",
label: "Games",
value: selectedGameIds.isEmpty ? nil : "\(selectedGameIds.count) game\(selectedGameIds.count == 1 ? "" : "s")",
placeholder: "Select games",
isEnabled: !selectedTeamIds.isEmpty,
onTap: { showGamesPicker = true },
onClear: { selectedGameIds = [] }
)
// Selected Games Summary
if !selectedGameIds.isEmpty {
selectedGamesSummary
// Date Range Section - shown when games are selected
dateRangeSection
}
}
.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: $showSportsPicker) {
SportsPickerSheet(selectedSports: $selectedSports) {
// Clear downstream when sports change
selectedTeamIds = []
selectedGameIds = []
}
}
.sheet(isPresented: $showTeamsPicker) {
TeamsPickerSheet(
selectedSports: selectedSports,
selectedTeamIds: $selectedTeamIds
) {
// Clear games when teams change
selectedGameIds = []
}
}
.sheet(isPresented: $showGamesPicker) {
GamesPickerSheet(
selectedTeamIds: selectedTeamIds,
selectedGameIds: $selectedGameIds
)
}
}
// MARK: - Selection Row
private func selectionRow(
icon: String,
label: String,
value: String?,
placeholder: String,
isEnabled: Bool = true,
onTap: @escaping () -> Void,
onClear: @escaping () -> Void
) -> some View {
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
Text(label)
.font(.caption)
.fontWeight(.medium)
.foregroundStyle(Theme.textSecondary(colorScheme))
Button {
if isEnabled { onTap() }
} label: {
HStack {
Image(systemName: icon)
.foregroundStyle(isEnabled ? Theme.warmOrange : Theme.textMuted(colorScheme))
if let value = value {
Text(value)
.font(.subheadline)
.foregroundStyle(Theme.textPrimary(colorScheme))
.lineLimit(1)
Spacer()
Button(action: onClear) {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(Theme.textMuted(colorScheme))
}
} else {
Text(placeholder)
.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(value != nil ? Theme.warmOrange : Theme.textMuted(colorScheme).opacity(0.3), lineWidth: value != nil ? 2 : 1)
)
.opacity(isEnabled ? 1 : 0.5)
}
.buttonStyle(.plain)
.disabled(!isEnabled)
}
}
// MARK: - Selected Games Summary
@State private var summaryGames: [RichGame] = []
private var selectedGamesSummary: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Theme.warmOrange)
Text("\(selectedGameIds.count) game\(selectedGameIds.count == 1 ? "" : "s") selected")
.font(.subheadline)
.fontWeight(.medium)
.foregroundStyle(Theme.textPrimary(colorScheme))
}
ForEach(summaryGames.filter { selectedGameIds.contains($0.id) }) { game in
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(game.matchupDescription)
.font(.caption)
.fontWeight(.medium)
.foregroundStyle(Theme.textPrimary(colorScheme))
Text("\(game.stadium.city)\(game.game.dateTime, style: .date)")
.font(.caption2)
.foregroundStyle(Theme.textMuted(colorScheme))
}
Spacer()
Button {
selectedGameIds.remove(game.id)
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(Theme.textMuted(colorScheme))
}
}
.padding(Theme.Spacing.sm)
.background(Theme.cardBackgroundElevated(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.small))
}
}
.padding(Theme.Spacing.sm)
.background(Theme.warmOrange.opacity(0.1))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
.task(id: selectedGameIds) {
await loadSummaryGames()
}
}
private func loadSummaryGames() async {
var games: [RichGame] = []
for teamId in selectedTeamIds {
if let teamGames = try? await AppDataProvider.shared.gamesForTeam(teamId: teamId) {
games.append(contentsOf: teamGames)
}
}
await MainActor.run {
summaryGames = Array(Set(games))
}
// Update date range after games are loaded (not before)
await updateDateRangeForSelectedGames()
}
// MARK: - Date Range Section
private var dateRangeSection: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
HStack {
Image(systemName: "calendar")
.foregroundStyle(Theme.warmOrange)
Text("Trip Date Range")
.font(.subheadline)
.fontWeight(.medium)
.foregroundStyle(Theme.textPrimary(colorScheme))
Spacer()
// Show auto-calculated indicator
Text("Auto-adjusted")
.font(.caption2)
.foregroundStyle(Theme.textMuted(colorScheme))
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Theme.cardBackgroundElevated(colorScheme))
.clipShape(Capsule())
}
DateRangePicker(startDate: $startDate, endDate: $endDate)
// Game date markers legend
if !summaryGames.isEmpty {
let selectedGamesWithDates = summaryGames.filter { selectedGameIds.contains($0.id) }
if !selectedGamesWithDates.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Text("Game dates:")
.font(.caption2)
.foregroundStyle(Theme.textMuted(colorScheme))
ForEach(selectedGamesWithDates.sorted { $0.game.dateTime < $1.game.dateTime }) { game in
HStack(spacing: 6) {
Circle()
.fill(game.game.sport.themeColor)
.frame(width: 6, height: 6)
Text("\(game.matchupDescription) - \(game.game.dateTime, style: .date)")
.font(.caption2)
.foregroundStyle(Theme.textSecondary(colorScheme))
}
}
}
.padding(.top, Theme.Spacing.xs)
}
}
}
.padding(Theme.Spacing.md)
.background(Theme.cardBackgroundElevated(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
}
/// Updates the date range based on selected games
/// - Single game: 7-day span centered on game (position 4 of 7, so game is day 4)
/// - Multiple games: range from earliest to latest with 1-day buffer
private func updateDateRangeForSelectedGames() async {
let selectedGames = summaryGames.filter { selectedGameIds.contains($0.id) }
guard !selectedGames.isEmpty else { return }
let gameDates = selectedGames.map { $0.game.dateTime }.sorted()
let calendar = Calendar.current
await MainActor.run {
if gameDates.count == 1 {
// Single game: 7-day span centered on game
// Position 4 of 7 means: 3 days before, game day, 3 days after
let gameDate = gameDates[0]
let newStart = calendar.date(byAdding: .day, value: -3, to: gameDate) ?? gameDate
let newEnd = calendar.date(byAdding: .day, value: 3, to: gameDate) ?? gameDate
startDate = calendar.startOfDay(for: newStart)
endDate = calendar.startOfDay(for: newEnd)
} else {
// Multiple games: span from first to last with 1-day buffer
let firstGameDate = gameDates.first!
let lastGameDate = gameDates.last!
let newStart = calendar.date(byAdding: .day, value: -1, to: firstGameDate) ?? firstGameDate
let newEnd = calendar.date(byAdding: .day, value: 1, to: lastGameDate) ?? lastGameDate
startDate = calendar.startOfDay(for: newStart)
endDate = calendar.startOfDay(for: newEnd)
}
}
}
}
// MARK: - Sports Picker Sheet
private struct SportsPickerSheet: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var colorScheme
@Binding var selectedSports: Set<Sport>
let onChanged: () -> Void
var body: some View {
NavigationStack {
List {
ForEach(Sport.supported, id: \.self) { sport in
Button {
if selectedSports.contains(sport) {
selectedSports.remove(sport)
} else {
selectedSports.insert(sport)
}
onChanged()
} 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()
if selectedSports.contains(sport) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Theme.warmOrange)
} else {
Image(systemName: "circle")
.foregroundStyle(Theme.textMuted(colorScheme))
}
}
.padding(.vertical, Theme.Spacing.xs)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
.listStyle(.plain)
.navigationTitle("Select Sports")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
.fontWeight(.semibold)
}
}
}
.presentationDetents([.medium])
}
}
// MARK: - Teams Picker Sheet
private struct TeamsPickerSheet: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var colorScheme
let selectedSports: Set<Sport>
@Binding var selectedTeamIds: Set<String>
let onChanged: () -> Void
@State private var searchText = ""
private var teams: [Team] {
let allTeams = AppDataProvider.shared.teams
.filter { selectedSports.contains($0.sport) }
.sorted { $0.fullName < $1.fullName }
if searchText.isEmpty {
return allTeams
}
return allTeams.filter {
$0.fullName.localizedCaseInsensitiveContains(searchText) ||
$0.city.localizedCaseInsensitiveContains(searchText)
}
}
private var groupedTeams: [(Sport, [Team])] {
let grouped = Dictionary(grouping: teams) { $0.sport }
return selectedSports.sorted { $0.rawValue < $1.rawValue }
.compactMap { sport in
guard let sportTeams = grouped[sport], !sportTeams.isEmpty else { return nil }
return (sport, sportTeams)
}
}
var body: some View {
NavigationStack {
List {
ForEach(groupedTeams, id: \.0) { sport, sportTeams in
Section {
ForEach(sportTeams) { team in
Button {
if selectedTeamIds.contains(team.id) {
selectedTeamIds.remove(team.id)
} else {
selectedTeamIds.insert(team.id)
}
onChanged()
} 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))
}
}
.padding(.vertical, Theme.Spacing.xs)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
} header: {
HStack(spacing: Theme.Spacing.xs) {
Image(systemName: sport.iconName)
.foregroundStyle(sport.themeColor)
Text(sport.rawValue)
}
}
}
}
.listStyle(.plain)
.searchable(text: $searchText, prompt: "Search teams")
.navigationTitle("Select Teams")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
.fontWeight(.semibold)
}
}
}
.presentationDetents([.large])
}
}
// MARK: - Games Picker Sheet
private struct GamesPickerSheet: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var colorScheme
let selectedTeamIds: Set<String>
@Binding var selectedGameIds: Set<String>
@State private var games: [RichGame] = []
@State private var isLoading = true
private var groupedGames: [(Date, [RichGame])] {
let grouped = Dictionary(grouping: games) { game in
Calendar.current.startOfDay(for: game.game.dateTime)
}
return grouped.keys.sorted().map { date in
(date, grouped[date] ?? [])
}
}
var body: some View {
NavigationStack {
Group {
if isLoading {
ProgressView("Loading games...")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if games.isEmpty {
ContentUnavailableView(
"No Games Found",
systemImage: "ticket",
description: Text("No upcoming games found for the selected teams")
)
} else {
List {
ForEach(groupedGames, id: \.0) { date, dateGames in
Section {
ForEach(dateGames) { game in
Button {
if selectedGameIds.contains(game.id) {
selectedGameIds.remove(game.id)
} else {
selectedGameIds.insert(game.id)
}
} label: {
HStack {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: Theme.Spacing.xs) {
Image(systemName: game.game.sport.iconName)
.font(.caption)
.foregroundStyle(game.game.sport.themeColor)
Text(game.matchupDescription)
.font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme))
}
Text("\(game.stadium.name)\(game.localGameTimeShort)")
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
}
Spacer()
if selectedGameIds.contains(game.id) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(Theme.warmOrange)
} else {
Image(systemName: "circle")
.foregroundStyle(Theme.textMuted(colorScheme))
}
}
.padding(.vertical, Theme.Spacing.xs)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
} header: {
Text(date, style: .date)
}
}
}
.listStyle(.plain)
}
}
.navigationTitle("Select Games")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
.fontWeight(.semibold)
}
}
}
.presentationDetents([.large])
.task {
await loadGames()
}
}
private func loadGames() async {
var allGames: [RichGame] = []
for teamId in selectedTeamIds {
if let teamGames = try? await AppDataProvider.shared.gamesForTeam(teamId: teamId) {
let futureGames = teamGames.filter { $0.game.dateTime > Date() }
allGames.append(contentsOf: futureGames)
}
}
let uniqueGames = Array(Set(allGames)).sorted { $0.game.dateTime < $1.game.dateTime }
await MainActor.run {
games = uniqueGames
isLoading = false
}
}
}
// MARK: - Preview
#Preview {
GamePickerStep(
selectedSports: .constant([.mlb]),
selectedTeamIds: .constant([]),
selectedGameIds: .constant([]),
startDate: .constant(Date()),
endDate: .constant(Date().addingTimeInterval(86400 * 7))
)
.padding()
.themedBackground()
}