From 6db0bdefcdabb63fefed9202639b82f18c9f4094 Mon Sep 17 00:00:00 2001 From: Trey t Date: Sat, 10 Jan 2026 17:31:16 -0600 Subject: [PATCH] fix(trip): redesign By Games mode with hierarchical calendar picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace navigation-based team→games flow with expandable Sport→Team→Date hierarchy. Games now grouped by date under each team with inline selection. Also fixed game loading to always fetch 90-day browsing window. Co-Authored-By: Claude Opus 4.5 --- PROJECT_STATE.md | 287 ++++------ .../Trip/Views/TripCreationView.swift | 501 +++++++++++------- 2 files changed, 414 insertions(+), 374 deletions(-) diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 3fbc3a3..74842ad 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -1,18 +1,3 @@ -# Claude Code State & Control System - -This document contains **everything you need** to keep Claude Code from losing context, looping, or rewriting history: - -1. A canonical `PROJECT_STATE.md` file template -2. A full set of **copy‑paste prompts** for working with Claude Code -3. Claude Code **hooks / scripts** to automate checkpoints and recovery - ---- - -## 1️⃣ PROJECT_STATE.md (CANONICAL FILE) - -Create this file at the root of your repo. - -```md # PROJECT STATE — CANONICAL ⚠️ This file is the single source of truth. @@ -22,203 +7,153 @@ Create this file at the root of your repo. --- ## Goal (LOCKED) - +Build a functional iOS sports trip planning app that helps users plan multi-stop road trips to attend games across MLB, NBA, NHL, NFL, MLS, WNBA, and NWSL. The app creates optimized routes with 3 planning modes: by dates, by must-see games, or by start/end cities. Ongoing development with no fixed completion milestone. -## Non‑Negotiable Constraints (LOCKED) -- No re‑evaluation of prior decisions +## Non-Negotiable Constraints (LOCKED) + +- No re-evaluation of prior decisions - No alternative architectures unless explicitly requested - No refactors outside the active task - No scope expansion - +- iOS 26 minimum target (do not support older iOS versions) +- SwiftData + CloudKit + offline-first architecture is locked +- Three-layer architecture (Presentation/Domain/Data) is locked +- Python pipeline for data scraping remains (even though it needs rewrite) ## Architecture Decisions (LOCKED) -- -- +- **Data flow**: Python scrape → canonicalize → CloudKit → SwiftData → AppDataProvider.shared (single source of truth) +- **Trip planning**: 3 scenario modes (A: by dates, B: must-see games, C: start/end cities) using GameDAGRouter with beam search +- **Offline-first**: Bundled JSON bootstrap → SwiftData → background CloudKit sync +- **Test framework**: Swift Testing (@Test/@Suite syntax, not XCTest) +- **Sports supported**: 7 leagues (MLB, NBA, NHL, NFL, MLS, WNBA, NWSL), 148 stadiums +- **Export**: PDF itinerary generation with maps, photos, POIs ## Current Phase - +**No active phase** — Previous phase-based workflow (.planning/) was removed as failed organization system. Ignore all git commit references to "Phase 8", "Phase 9", "v1.1 milestone" etc. ## Active Tasks - -- [ ] -- [ ] +- [x] Fix "By Games" Mode Game Selection +- [ ] Group Schedule View Games by Sport +- [ ] Remove Buffer Days from Trip Planner ## Completed Tasks -- [x] +**Core Features (Working):** +- [x] Trip planning engine with 3 scenario modes (A/B/C) +- [x] GameDAGRouter with beam search optimization +- [x] Offline-first data architecture (bundled JSON → SwiftData → CloudKit) +- [x] AppDataProvider as single source of truth +- [x] Stadium progress tracking with photo import +- [x] Achievement system +- [x] PDF trip export with maps and POIs +- [x] UI: Home, Trip Creation/Detail, Schedule, Progress, Settings views +- [x] Python data pipeline (scraping, canonicalization, CloudKit upload) — functional but needs rewrite +- [x] 7 sport modules with multi-source fallback architecture -## Open Questions (User‑Owned) - -- +**Recent Work (from git history, prior to .planning/ removal):** +- [x] GameDAGRouter edge case tests +- [x] Performance optimization for large datasets (10-17x speedup) +- [x] Scenario A timezone and conflict tests +- [x] Scenario B filler conflict tests +- [x] Scenario C corridor efficiency and anti-backtracking tests +## Open Questions (User-Owned) -## Checkpoints (APPEND‑ONLY) +- Data quality: User is "unsure" if the 148 stadiums across 7 sports are accurate and complete +- CloudKit sync: "As far as I know yes" — not 100% certain sync is working correctly +- Test implementation completeness: Tests exist but are broken — unclear if implementations they test are actually complete +- When to rewrite Python pipeline: Acknowledged as needed but not scheduled -### Checkpoint YYYY‑MM‑DD HH:MM -- What exists: -- What is missing: -- Known issues: -- Next step: -``` +## Known Issues ---- +1. **Test suite broken** — Tests do not run in parallel OR serial execution. Files and tests need complete revisit. (NOT BLOCKING: app is functional) +2. **Data scraping broken** — Python pipeline "messed up" data, data directories (`data/canonical/`, `data/games/`) emptied. CloudKit still has data, so app functions. +3. **5 flaky tests** — Fail in parallel but pass individually (Swift Testing + simulator state pollution). Ignoring for now. +4. **Python pipeline out of control** — Grown unwieldy, needs complete rewrite from scratch (future task). -## 2️⃣ CLAUDE CODE PROMPTS (COPY / PASTE) +## Deferred Work -Save this section as `CLAUDE_PROMPTS.md` if you want it separate. +All items in TO-DOS.md (16 items) are deferred — will become tasks in future phases when prioritized. See TO-DOS.md for full list. ---- +**High-level categories:** +- In-app purchases (StoreKit, receipt validation) +- UI/UX improvements (redesign, theme fixes, region picker map) +- Testing gaps (trip filtering, repeat cities, must-stops, driving limits) +- Schedule improvements (UTC→local timezone, group by sport, show all games) +- Buffer days removal -### 🔹 Initialize Project State -``` -Create or update PROJECT_STATE.md. -Write the goal, non‑negotiable constraints, architecture decisions, and an initial task list. -Do NOT write code. -Do NOT speculate. -This file is canonical. -``` +**Feature flags (disabled, future work):** +- EV charging (`FeatureFlags.enableEVCharging = false`) +- Foundation Models / AI descriptions (commented out in RouteDescriptionGenerator) ---- +## What Exists -### 🔹 Start Any Work Session -``` -Before doing anything, read PROJECT_STATE.md in full. -Summarize: -- Goal -- Current Phase -- Active Tasks -Then proceed with the first unfinished task only. -Do not modify PROJECT_STATE.md unless explicitly told to. -``` +**iOS App (SportsTime/):** +- Functional trip planning: 3 scenario modes, can create and save trips +- GameDAGRouter: Graph routing with beam search, directional filtering, performance-optimized +- Data layer: SwiftData models (SavedTrip, StadiumVisit, CanonicalStadium, CanonicalTeam, CanonicalGame) +- Services: CloudKitService, CanonicalSyncService, BootstrapService, LocationService, AchievementEngine +- UI: 5 main views (Home, Trip, Schedule, Progress, Settings), fully SwiftUI with @Observable ViewModels +- Export: PDF generator with map snapshots, remote image caching, POI search +- Progress tracking: Stadium visits, photos, achievements, progress map +- ~27,500 lines of Swift code ---- +**Python Pipeline (Scripts/):** +- 7 sport scrapers (mlb, nba, nhl, nfl, mls, wnba, nwsl) +- Canonicalization pipeline (stadiums, teams, games with alias resolution) +- CloudKit import (full CRUD with diff reporting) +- Validation tools (health scores, completeness metrics) +- ~12,000 lines of Python code +- Status: Functional but "out of control", needs rewrite -### 🔹 Execute a Task (No Drift) -``` -Work only on the selected Active Task. -Do not introduce new abstractions. -Do not refactor unrelated code. -Do not re‑analyze architecture. -Produce the minimum change required. -``` +**Tests:** +- 6 test files, ~7,000 lines of Swift Testing code +- Coverage: GameDAGRouter, ScenarioA/B/C planners, TravelEstimator +- Status: BROKEN (don't run in parallel or serial) ---- +**Documentation:** +- CLAUDE.md: Comprehensive iOS app docs, build commands, architecture +- ARCHITECTURE.md: Original design document +- docs/: Market research, stadium progress spec, WNBA/MLS/NWSL implementation guides, data scraping architecture +- Scripts/: README, DATA_SOURCES, CLOUDKIT_SETUP -### 🔹 Write a Checkpoint -``` -Write a new checkpoint to PROJECT_STATE.md. -Append only under the Checkpoints section. -Do not modify earlier content. -Summarize truthfully: -- What exists -- What is missing -- Known issues -- Next step -``` +**Data:** +- Bundled JSON in `SportsTime/Resources/` (games_canonical.json, stadiums_canonical.json, teams_canonical.json, etc.) +- CloudKit container: `iCloud.com.sportstime.app` (contains current schedule data) +- Local data directories (`data/`) emptied after pipeline issues ---- +## What Is Missing -### 🔹 Context Reset / Recovery -``` -Clear context. -Read PROJECT_STATE.md completely. -Summarize: -- Goal -- Current Phase -- Active Tasks -Then continue from the next unfinished task. -``` +**Broken/Incomplete:** +- Test suite execution (doesn't run properly) +- Python pipeline stable/maintainable implementation (current version works but needs rewrite) +- Data quality confidence (user unsure if 148 stadiums are accurate) ---- +**Future Features (from TO-DOS.md):** +- In-app purchases / subscription system +- UI redesign +- Trip filtering +- Repeat cities option testing +- Must-stops (non-game POIs) testing +- Full test coverage for constraints (driving limits, etc.) +- Schedule improvements (timezone conversion, grouping by sport) +- Interactive region picker map -### 🔹 Scope Guard (When Claude Starts Drifting) -``` -Stop. -This is out of scope. -Re‑read PROJECT_STATE.md. -Return to the current Active Task. -``` +**Future Enhancements (from docs/MARKET_RESEARCH.md):** +- AI trip assistant (natural language planning) +- Group trip coordination +- Ticket integration +- Fan community features ---- +## Checkpoints (APPEND-ONLY) -### 🔹 Lock or Unlock Sections -``` -Unlock the following section(s): --
-No other sections may be modified. -``` - ---- - -## 3️⃣ CLAUDE CODE HOOKS / SCRIPTS - -These assume Claude Code can run shell commands or that you trigger them manually. - ---- - -### 🧠 Auto‑Checkpoint Script -Create `checkpoint.sh` - -```bash -#!/usr/bin/env bash - -echo "\n### Checkpoint $(date '+%Y-%m-%d %H:%M')" >> PROJECT_STATE.md -echo "- What exists:" >> PROJECT_STATE.md -echo "- What is missing:" >> PROJECT_STATE.md -echo "- Known issues:" >> PROJECT_STATE.md -echo "- Next step:" >> PROJECT_STATE.md -echo "" >> PROJECT_STATE.md -``` - -Use prompt: -``` -Run checkpoint.sh and then fill in the new checkpoint accurately. -``` - ---- - -### 🔁 Session Start Hook -Create `session_start.md` - -```md -Read PROJECT_STATE.md. -You may not write code until you summarize: -- Goal -- Current Phase -- Active Tasks -``` - -Paste this at the top of every new Claude Code session. - ---- - -### 🧯 Emergency Reset Hook -Create `RESET.md` - -```md -STOP ALL WORK. -Discard assumptions from previous context. -Read PROJECT_STATE.md in full. -State what the next correct action is. -Do not code until confirmed. -``` - ---- - -## Final Rule (Read This Once) - -Claude Code is a **stateless executor**, not a planner. - -This system turns it into: -- A reliable implementer -- A resumable worker -- A non‑looping assistant - -If Claude starts looping, drifting, or "thinking creatively" — it means the rails weren’t explicit enough. - -Tighten the rails. +### Checkpoint 2026-01-10 16:30 +- What exists: Functional iOS app with trip planning (3 modes), stadium tracking, PDF export. Python pipeline functional. CloudKit contains current data. +- What is missing: Working test suite (broken), stable Python pipeline (needs rewrite), data quality validation +- Known issues: Tests don't run (parallel or serial), data scraping "messed up" data (CloudKit still OK), flaky tests +- Next step: Fix broken test suite as first active task diff --git a/SportsTime/Features/Trip/Views/TripCreationView.swift b/SportsTime/Features/Trip/Views/TripCreationView.swift index 98224e4..b4b7cec 100644 --- a/SportsTime/Features/Trip/Views/TripCreationView.swift +++ b/SportsTime/Features/Trip/Views/TripCreationView.swift @@ -408,10 +408,8 @@ struct TripCreationView: View { .frame(maxWidth: .infinity, alignment: .center) .padding(.vertical, Theme.Spacing.md) .task(id: viewModel.selectedSports) { - // Re-run when sports selection changes - if viewModel.availableGames.isEmpty { - await viewModel.loadGamesForBrowsing() - } + // Always load 90-day browsing window for gameFirst mode + await viewModel.loadGamesForBrowsing() } } else { Button { @@ -822,93 +820,99 @@ extension TripCreationViewModel.ViewState { } } -// MARK: - Game Picker Sheet (Team-based selection) +// MARK: - Game Picker Sheet (Calendar view: Sport → Team → Date) struct GamePickerSheet: View { let games: [RichGame] @Binding var selectedIds: Set @Environment(\.dismiss) private var dismiss + @Environment(\.colorScheme) private var colorScheme - // Group games by team (both home and away) - private var teamsList: [TeamWithGames] { - var teamsDict: [UUID: TeamWithGames] = [:] + @State private var expandedSports: Set = [] + @State private var expandedTeams: Set = [] + + // Group games by Sport → Team (home team only to avoid duplicates) + private var gamesBySport: [Sport: [TeamWithGames]] { + var result: [Sport: [UUID: TeamWithGames]] = [:] for game in games { - // Add to home team - if var teamData = teamsDict[game.homeTeam.id] { - teamData.games.append(game) - teamsDict[game.homeTeam.id] = teamData - } else { - teamsDict[game.homeTeam.id] = TeamWithGames( - team: game.homeTeam, - sport: game.game.sport, - games: [game] - ) + let sport = game.game.sport + let team = game.homeTeam + + if result[sport] == nil { + result[sport] = [:] } - // Add to away team - if var teamData = teamsDict[game.awayTeam.id] { + if var teamData = result[sport]?[team.id] { teamData.games.append(game) - teamsDict[game.awayTeam.id] = teamData + result[sport]?[team.id] = teamData } else { - teamsDict[game.awayTeam.id] = TeamWithGames( - team: game.awayTeam, - sport: game.game.sport, + result[sport]?[team.id] = TeamWithGames( + team: team, + sport: sport, games: [game] ) } } - return teamsDict.values - .sorted { $0.team.name < $1.team.name } + // Convert to sorted arrays + var sortedResult: [Sport: [TeamWithGames]] = [:] + for (sport, teamsDict) in result { + sortedResult[sport] = teamsDict.values.sorted { $0.team.name < $1.team.name } + } + return sortedResult } - private var teamsBySport: [(sport: Sport, teams: [TeamWithGames])] { - let grouped = Dictionary(grouping: teamsList) { $0.sport } - return Sport.supported - .filter { grouped[$0] != nil } - .map { sport in - (sport, grouped[sport]!.sorted { $0.team.name < $1.team.name }) - } + private var sortedSports: [Sport] { + Sport.supported.filter { gamesBySport[$0] != nil } } private var selectedGamesCount: Int { selectedIds.count } + private func selectedCountForSport(_ sport: Sport) -> Int { + guard let teams = gamesBySport[sport] else { return 0 } + return teams.flatMap { $0.games }.filter { selectedIds.contains($0.id) }.count + } + + private func selectedCountForTeam(_ teamData: TeamWithGames) -> Int { + teamData.games.filter { selectedIds.contains($0.id) }.count + } + var body: some View { NavigationStack { - List { - // Selected games summary - if !selectedIds.isEmpty { - Section { + ScrollView { + LazyVStack(spacing: 0) { + // Selected games summary + if !selectedIds.isEmpty { HStack { Image(systemName: "checkmark.circle.fill") .foregroundStyle(.green) Text("\(selectedGamesCount) game(s) selected") - .fontWeight(.medium) + .font(.system(size: 15, weight: .semibold)) Spacer() } + .padding(Theme.Spacing.md) + .background(Theme.cardBackground(colorScheme)) } - } - // Teams by sport - ForEach(teamsBySport, id: \.sport.id) { sportGroup in - Section(sportGroup.sport.rawValue) { - ForEach(sportGroup.teams) { teamData in - NavigationLink { - TeamGamesView( - teamData: teamData, - selectedIds: $selectedIds - ) - } label: { - TeamRow(teamData: teamData, selectedIds: selectedIds) - } - } + // Sport sections + ForEach(sortedSports) { sport in + SportSection( + sport: sport, + teams: gamesBySport[sport] ?? [], + selectedIds: $selectedIds, + expandedSports: $expandedSports, + expandedTeams: $expandedTeams, + selectedCount: selectedCountForSport(sport) + ) } } } - .navigationTitle("Select Teams") + .themedBackground() + .navigationTitle("Select Games") + .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { if !selectedIds.isEmpty { @@ -922,12 +926,258 @@ struct GamePickerSheet: View { Button("Done") { dismiss() } + .fontWeight(.semibold) } } } } } +// MARK: - Sport Section + +struct SportSection: View { + let sport: Sport + let teams: [TeamWithGames] + @Binding var selectedIds: Set + @Binding var expandedSports: Set + @Binding var expandedTeams: Set + let selectedCount: Int + + @Environment(\.colorScheme) private var colorScheme + + private var isExpanded: Bool { + expandedSports.contains(sport) + } + + var body: some View { + VStack(spacing: 0) { + // Sport header + Button { + withAnimation(.easeInOut(duration: 0.2)) { + if isExpanded { + expandedSports.remove(sport) + } else { + expandedSports.insert(sport) + } + } + } label: { + HStack(spacing: Theme.Spacing.sm) { + Image(systemName: sport.iconName) + .font(.system(size: 20)) + .foregroundStyle(sport.themeColor) + .frame(width: 32) + + Text(sport.rawValue) + .font(.system(size: 17, weight: .bold)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + Text("\(teams.flatMap { $0.games }.count) games") + .font(.system(size: 13)) + .foregroundStyle(Theme.textMuted(colorScheme)) + + Spacer() + + if selectedCount > 0 { + Text("\(selectedCount)") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(.white) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(sport.themeColor) + .clipShape(Capsule()) + } + + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(Theme.textMuted(colorScheme)) + .rotationEffect(.degrees(isExpanded ? 90 : 0)) + } + .padding(Theme.Spacing.md) + .background(Theme.cardBackground(colorScheme)) + } + .buttonStyle(.plain) + + // Teams list (when expanded) + if isExpanded { + VStack(spacing: 0) { + ForEach(teams) { teamData in + TeamSection( + teamData: teamData, + selectedIds: $selectedIds, + expandedTeams: $expandedTeams + ) + } + } + .padding(.leading, Theme.Spacing.lg) + } + + Divider() + .overlay(Theme.surfaceGlow(colorScheme)) + } + } +} + +// MARK: - Team Section + +struct TeamSection: View { + let teamData: TeamWithGames + @Binding var selectedIds: Set + @Binding var expandedTeams: Set + + @Environment(\.colorScheme) private var colorScheme + + private var isExpanded: Bool { + expandedTeams.contains(teamData.id) + } + + private var selectedCount: Int { + teamData.games.filter { selectedIds.contains($0.id) }.count + } + + // Group games by date + private var gamesByDate: [(date: String, games: [RichGame])] { + let grouped = Dictionary(grouping: teamData.sortedGames) { game in + game.game.formattedDate + } + return grouped.sorted { $0.value.first!.game.dateTime < $1.value.first!.game.dateTime } + .map { (date: $0.key, games: $0.value) } + } + + var body: some View { + VStack(spacing: 0) { + // Team header + Button { + withAnimation(.easeInOut(duration: 0.2)) { + if isExpanded { + expandedTeams.remove(teamData.id) + } else { + expandedTeams.insert(teamData.id) + } + } + } label: { + HStack(spacing: Theme.Spacing.sm) { + // Team color + if let colorHex = teamData.team.primaryColor { + Circle() + .fill(Color(hex: colorHex)) + .frame(width: 10, height: 10) + } + + Text("\(teamData.team.city) \(teamData.team.name)") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + Text("\(teamData.games.count)") + .font(.system(size: 12)) + .foregroundStyle(Theme.textMuted(colorScheme)) + + Spacer() + + if selectedCount > 0 { + Text("\(selectedCount)") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(.white) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(Theme.warmOrange) + .clipShape(Capsule()) + } + + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(Theme.textMuted(colorScheme)) + .rotationEffect(.degrees(isExpanded ? 90 : 0)) + } + .padding(.vertical, Theme.Spacing.sm) + .padding(.horizontal, Theme.Spacing.md) + .background(Theme.cardBackgroundElevated(colorScheme)) + } + .buttonStyle(.plain) + + // Games grouped by date (when expanded) + if isExpanded { + VStack(spacing: 0) { + ForEach(gamesByDate, id: \.date) { dateGroup in + VStack(alignment: .leading, spacing: 0) { + // Date header + Text(dateGroup.date) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(Theme.warmOrange) + .padding(.horizontal, Theme.Spacing.md) + .padding(.top, Theme.Spacing.sm) + .padding(.bottom, Theme.Spacing.xs) + + // Games on this date + ForEach(dateGroup.games) { game in + GameCalendarRow( + game: game, + isSelected: selectedIds.contains(game.id), + onTap: { + if selectedIds.contains(game.id) { + selectedIds.remove(game.id) + } else { + selectedIds.insert(game.id) + } + } + ) + } + } + } + } + .padding(.leading, Theme.Spacing.md) + .background(Theme.cardBackgroundElevated(colorScheme).opacity(0.5)) + } + } + } +} + +// MARK: - Game Calendar Row + +struct GameCalendarRow: View { + let game: RichGame + let isSelected: Bool + let onTap: () -> Void + + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + Button(action: onTap) { + HStack(spacing: Theme.Spacing.sm) { + // Selection indicator + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .font(.system(size: 22)) + .foregroundStyle(isSelected ? Theme.warmOrange : Theme.textMuted(colorScheme)) + + VStack(alignment: .leading, spacing: 2) { + Text("vs \(game.awayTeam.name)") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + HStack(spacing: Theme.Spacing.xs) { + Text(game.game.gameTime) + .font(.system(size: 12)) + .foregroundStyle(Theme.textSecondary(colorScheme)) + + Text("•") + .foregroundStyle(Theme.textMuted(colorScheme)) + + Text(game.stadium.name) + .font(.system(size: 12)) + .foregroundStyle(Theme.textMuted(colorScheme)) + .lineLimit(1) + } + } + + Spacer() + } + .padding(.vertical, Theme.Spacing.sm) + .padding(.horizontal, Theme.Spacing.md) + .background(isSelected ? Theme.warmOrange.opacity(0.1) : Color.clear) + } + .buttonStyle(.plain) + } +} + // MARK: - Team With Games Model struct TeamWithGames: Identifiable { @@ -942,151 +1192,6 @@ struct TeamWithGames: Identifiable { } } -// MARK: - Team Row - -struct TeamRow: View { - let teamData: TeamWithGames - let selectedIds: Set - - private var selectedCount: Int { - teamData.games.filter { selectedIds.contains($0.id) }.count - } - - var body: some View { - HStack(spacing: 12) { - // Team color indicator - if let colorHex = teamData.team.primaryColor { - Circle() - .fill(Color(hex: colorHex)) - .frame(width: 12, height: 12) - } - - VStack(alignment: .leading, spacing: 2) { - Text("\(teamData.team.city) \(teamData.team.name)") - .font(.subheadline) - .fontWeight(.medium) - - Text("\(teamData.games.count) game(s) available") - .font(.caption) - .foregroundStyle(.secondary) - } - - Spacer() - - if selectedCount > 0 { - Text("\(selectedCount)") - .font(.caption) - .fontWeight(.bold) - .foregroundStyle(.white) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(.blue) - .clipShape(Capsule()) - } - } - } -} - -// MARK: - Team Games View - -struct TeamGamesView: View { - let teamData: TeamWithGames - @Binding var selectedIds: Set - - var body: some View { - List { - ForEach(teamData.sortedGames) { game in - GamePickerRow(game: game, isSelected: selectedIds.contains(game.id)) { - if selectedIds.contains(game.id) { - selectedIds.remove(game.id) - } else { - selectedIds.insert(game.id) - } - } - } - } - .navigationTitle("\(teamData.team.city) \(teamData.team.name)") - .navigationBarTitleDisplayMode(.inline) - } -} - -struct GamePickerRow: View { - let game: RichGame - let isSelected: Bool - let onTap: () -> Void - @Environment(\.colorScheme) private var colorScheme - - var body: some View { - Button(action: onTap) { - HStack(spacing: 12) { - // Sport color bar - SportColorBar(sport: game.game.sport) - - VStack(alignment: .leading, spacing: 4) { - Text(game.matchupDescription) - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(Theme.textPrimary(colorScheme)) - - Text(game.venueDescription) - .font(.caption) - .foregroundStyle(Theme.textSecondary(colorScheme)) - - Text("\(game.game.formattedDate) • \(game.game.gameTime)") - .font(.caption2) - .foregroundStyle(Theme.textMuted(colorScheme)) - } - - Spacer() - - Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") - .foregroundStyle(isSelected ? Theme.warmOrange : Theme.textMuted(colorScheme)) - .font(.title2) - } - } - .buttonStyle(.plain) - } -} - -struct GameSelectRow: View { - let game: RichGame - let isSelected: Bool - let onTap: () -> Void - - var body: some View { - Button(action: onTap) { - HStack(spacing: 12) { - // Sport icon - Image(systemName: game.game.sport.iconName) - .font(.title3) - .foregroundStyle(isSelected ? .blue : .secondary) - .frame(width: 24) - - VStack(alignment: .leading, spacing: 2) { - Text("\(game.awayTeam.abbreviation) @ \(game.homeTeam.abbreviation)") - .font(.subheadline) - .fontWeight(.medium) - - Text("\(game.game.formattedDate) • \(game.game.gameTime)") - .font(.caption) - .foregroundStyle(.secondary) - - Text(game.stadium.city) - .font(.caption2) - .foregroundStyle(.tertiary) - } - - Spacer() - - Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") - .foregroundStyle(isSelected ? .blue : .gray.opacity(0.5)) - .font(.title3) - } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } -} - // MARK: - Location Search Sheet struct LocationSearchSheet: View {