fix(trip): redesign By Games mode with hierarchical calendar picker
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 <noreply@anthropic.com>
This commit is contained in:
287
PROJECT_STATE.md
287
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
|
# PROJECT STATE — CANONICAL
|
||||||
|
|
||||||
⚠️ This file is the single source of truth.
|
⚠️ This file is the single source of truth.
|
||||||
@@ -22,203 +7,153 @@ Create this file at the root of your repo.
|
|||||||
---
|
---
|
||||||
|
|
||||||
## Goal (LOCKED)
|
## Goal (LOCKED)
|
||||||
<!-- 1–3 sentences. Immutable unless explicitly changed by the user. -->
|
|
||||||
|
|
||||||
|
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)
|
## Non-Negotiable Constraints (LOCKED)
|
||||||
- No re‑evaluation of prior decisions
|
|
||||||
|
- No re-evaluation of prior decisions
|
||||||
- No alternative architectures unless explicitly requested
|
- No alternative architectures unless explicitly requested
|
||||||
- No refactors outside the active task
|
- No refactors outside the active task
|
||||||
- No scope expansion
|
- 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)
|
## 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
|
## Current Phase
|
||||||
<!-- Short name + description -->
|
|
||||||
|
|
||||||
|
**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
|
## Active Tasks
|
||||||
<!-- Only tasks Claude is allowed to work on -->
|
|
||||||
- [ ]
|
|
||||||
- [ ]
|
|
||||||
|
|
||||||
|
- [x] Fix "By Games" Mode Game Selection
|
||||||
|
- [ ] Group Schedule View Games by Sport
|
||||||
|
- [ ] Remove Buffer Days from Trip Planner
|
||||||
|
|
||||||
## Completed Tasks
|
## 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):**
|
||||||
<!-- Claude may not answer these unless asked -->
|
- [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
|
## Known Issues
|
||||||
- What exists:
|
|
||||||
- What is missing:
|
|
||||||
- Known issues:
|
|
||||||
- Next step:
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
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
|
**Feature flags (disabled, future work):**
|
||||||
```
|
- EV charging (`FeatureFlags.enableEVCharging = false`)
|
||||||
Create or update PROJECT_STATE.md.
|
- Foundation Models / AI descriptions (commented out in RouteDescriptionGenerator)
|
||||||
Write the goal, non‑negotiable constraints, architecture decisions, and an initial task list.
|
|
||||||
Do NOT write code.
|
|
||||||
Do NOT speculate.
|
|
||||||
This file is canonical.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
## What Exists
|
||||||
|
|
||||||
### 🔹 Start Any Work Session
|
**iOS App (SportsTime/):**
|
||||||
```
|
- Functional trip planning: 3 scenario modes, can create and save trips
|
||||||
Before doing anything, read PROJECT_STATE.md in full.
|
- GameDAGRouter: Graph routing with beam search, directional filtering, performance-optimized
|
||||||
Summarize:
|
- Data layer: SwiftData models (SavedTrip, StadiumVisit, CanonicalStadium, CanonicalTeam, CanonicalGame)
|
||||||
- Goal
|
- Services: CloudKitService, CanonicalSyncService, BootstrapService, LocationService, AchievementEngine
|
||||||
- Current Phase
|
- UI: 5 main views (Home, Trip, Schedule, Progress, Settings), fully SwiftUI with @Observable ViewModels
|
||||||
- Active Tasks
|
- Export: PDF generator with map snapshots, remote image caching, POI search
|
||||||
Then proceed with the first unfinished task only.
|
- Progress tracking: Stadium visits, photos, achievements, progress map
|
||||||
Do not modify PROJECT_STATE.md unless explicitly told to.
|
- ~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)
|
**Tests:**
|
||||||
```
|
- 6 test files, ~7,000 lines of Swift Testing code
|
||||||
Work only on the selected Active Task.
|
- Coverage: GameDAGRouter, ScenarioA/B/C planners, TravelEstimator
|
||||||
Do not introduce new abstractions.
|
- Status: BROKEN (don't run in parallel or serial)
|
||||||
Do not refactor unrelated code.
|
|
||||||
Do not re‑analyze architecture.
|
|
||||||
Produce the minimum change required.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
**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
|
**Data:**
|
||||||
```
|
- Bundled JSON in `SportsTime/Resources/` (games_canonical.json, stadiums_canonical.json, teams_canonical.json, etc.)
|
||||||
Write a new checkpoint to PROJECT_STATE.md.
|
- CloudKit container: `iCloud.com.sportstime.app` (contains current schedule data)
|
||||||
Append only under the Checkpoints section.
|
- Local data directories (`data/`) emptied after pipeline issues
|
||||||
Do not modify earlier content.
|
|
||||||
Summarize truthfully:
|
|
||||||
- What exists
|
|
||||||
- What is missing
|
|
||||||
- Known issues
|
|
||||||
- Next step
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
## What Is Missing
|
||||||
|
|
||||||
### 🔹 Context Reset / Recovery
|
**Broken/Incomplete:**
|
||||||
```
|
- Test suite execution (doesn't run properly)
|
||||||
Clear context.
|
- Python pipeline stable/maintainable implementation (current version works but needs rewrite)
|
||||||
Read PROJECT_STATE.md completely.
|
- Data quality confidence (user unsure if 148 stadiums are accurate)
|
||||||
Summarize:
|
|
||||||
- Goal
|
|
||||||
- Current Phase
|
|
||||||
- Active Tasks
|
|
||||||
Then continue from the next unfinished task.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
**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)
|
**Future Enhancements (from docs/MARKET_RESEARCH.md):**
|
||||||
```
|
- AI trip assistant (natural language planning)
|
||||||
Stop.
|
- Group trip coordination
|
||||||
This is out of scope.
|
- Ticket integration
|
||||||
Re‑read PROJECT_STATE.md.
|
- Fan community features
|
||||||
Return to the current Active Task.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
## Checkpoints (APPEND-ONLY)
|
||||||
|
|
||||||
### 🔹 Lock or Unlock Sections
|
### 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.
|
||||||
Unlock the following section(s):
|
- What is missing: Working test suite (broken), stable Python pipeline (needs rewrite), data quality validation
|
||||||
- <section name>
|
- Known issues: Tests don't run (parallel or serial), data scraping "messed up" data (CloudKit still OK), flaky tests
|
||||||
No other sections may be modified.
|
- Next step: Fix broken test suite as first active task
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
|
|||||||
@@ -408,10 +408,8 @@ struct TripCreationView: View {
|
|||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
.padding(.vertical, Theme.Spacing.md)
|
.padding(.vertical, Theme.Spacing.md)
|
||||||
.task(id: viewModel.selectedSports) {
|
.task(id: viewModel.selectedSports) {
|
||||||
// Re-run when sports selection changes
|
// Always load 90-day browsing window for gameFirst mode
|
||||||
if viewModel.availableGames.isEmpty {
|
await viewModel.loadGamesForBrowsing()
|
||||||
await viewModel.loadGamesForBrowsing()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Button {
|
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 {
|
struct GamePickerSheet: View {
|
||||||
let games: [RichGame]
|
let games: [RichGame]
|
||||||
@Binding var selectedIds: Set<UUID>
|
@Binding var selectedIds: Set<UUID>
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
// Group games by team (both home and away)
|
@State private var expandedSports: Set<Sport> = []
|
||||||
private var teamsList: [TeamWithGames] {
|
@State private var expandedTeams: Set<UUID> = []
|
||||||
var teamsDict: [UUID: TeamWithGames] = [:]
|
|
||||||
|
// 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 {
|
for game in games {
|
||||||
// Add to home team
|
let sport = game.game.sport
|
||||||
if var teamData = teamsDict[game.homeTeam.id] {
|
let team = game.homeTeam
|
||||||
teamData.games.append(game)
|
|
||||||
teamsDict[game.homeTeam.id] = teamData
|
if result[sport] == nil {
|
||||||
} else {
|
result[sport] = [:]
|
||||||
teamsDict[game.homeTeam.id] = TeamWithGames(
|
|
||||||
team: game.homeTeam,
|
|
||||||
sport: game.game.sport,
|
|
||||||
games: [game]
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add to away team
|
if var teamData = result[sport]?[team.id] {
|
||||||
if var teamData = teamsDict[game.awayTeam.id] {
|
|
||||||
teamData.games.append(game)
|
teamData.games.append(game)
|
||||||
teamsDict[game.awayTeam.id] = teamData
|
result[sport]?[team.id] = teamData
|
||||||
} else {
|
} else {
|
||||||
teamsDict[game.awayTeam.id] = TeamWithGames(
|
result[sport]?[team.id] = TeamWithGames(
|
||||||
team: game.awayTeam,
|
team: team,
|
||||||
sport: game.game.sport,
|
sport: sport,
|
||||||
games: [game]
|
games: [game]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return teamsDict.values
|
// Convert to sorted arrays
|
||||||
.sorted { $0.team.name < $1.team.name }
|
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])] {
|
private var sortedSports: [Sport] {
|
||||||
let grouped = Dictionary(grouping: teamsList) { $0.sport }
|
Sport.supported.filter { gamesBySport[$0] != nil }
|
||||||
return Sport.supported
|
|
||||||
.filter { grouped[$0] != nil }
|
|
||||||
.map { sport in
|
|
||||||
(sport, grouped[sport]!.sorted { $0.team.name < $1.team.name })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var selectedGamesCount: Int {
|
private var selectedGamesCount: Int {
|
||||||
selectedIds.count
|
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 {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
List {
|
ScrollView {
|
||||||
// Selected games summary
|
LazyVStack(spacing: 0) {
|
||||||
if !selectedIds.isEmpty {
|
// Selected games summary
|
||||||
Section {
|
if !selectedIds.isEmpty {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "checkmark.circle.fill")
|
Image(systemName: "checkmark.circle.fill")
|
||||||
.foregroundStyle(.green)
|
.foregroundStyle(.green)
|
||||||
Text("\(selectedGamesCount) game(s) selected")
|
Text("\(selectedGamesCount) game(s) selected")
|
||||||
.fontWeight(.medium)
|
.font(.system(size: 15, weight: .semibold))
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
|
.padding(Theme.Spacing.md)
|
||||||
|
.background(Theme.cardBackground(colorScheme))
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Teams by sport
|
// Sport sections
|
||||||
ForEach(teamsBySport, id: \.sport.id) { sportGroup in
|
ForEach(sortedSports) { sport in
|
||||||
Section(sportGroup.sport.rawValue) {
|
SportSection(
|
||||||
ForEach(sportGroup.teams) { teamData in
|
sport: sport,
|
||||||
NavigationLink {
|
teams: gamesBySport[sport] ?? [],
|
||||||
TeamGamesView(
|
selectedIds: $selectedIds,
|
||||||
teamData: teamData,
|
expandedSports: $expandedSports,
|
||||||
selectedIds: $selectedIds
|
expandedTeams: $expandedTeams,
|
||||||
)
|
selectedCount: selectedCountForSport(sport)
|
||||||
} label: {
|
)
|
||||||
TeamRow(teamData: teamData, selectedIds: selectedIds)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Select Teams")
|
.themedBackground()
|
||||||
|
.navigationTitle("Select Games")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
if !selectedIds.isEmpty {
|
if !selectedIds.isEmpty {
|
||||||
@@ -922,12 +926,258 @@ struct GamePickerSheet: View {
|
|||||||
Button("Done") {
|
Button("Done") {
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
|
.fontWeight(.semibold)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Sport Section
|
||||||
|
|
||||||
|
struct SportSection: View {
|
||||||
|
let sport: Sport
|
||||||
|
let teams: [TeamWithGames]
|
||||||
|
@Binding var selectedIds: Set<UUID>
|
||||||
|
@Binding var expandedSports: Set<Sport>
|
||||||
|
@Binding var expandedTeams: Set<UUID>
|
||||||
|
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<UUID>
|
||||||
|
@Binding var expandedTeams: Set<UUID>
|
||||||
|
|
||||||
|
@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
|
// MARK: - Team With Games Model
|
||||||
|
|
||||||
struct TeamWithGames: Identifiable {
|
struct TeamWithGames: Identifiable {
|
||||||
@@ -942,151 +1192,6 @@ struct TeamWithGames: Identifiable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Team Row
|
|
||||||
|
|
||||||
struct TeamRow: View {
|
|
||||||
let teamData: TeamWithGames
|
|
||||||
let selectedIds: Set<UUID>
|
|
||||||
|
|
||||||
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<UUID>
|
|
||||||
|
|
||||||
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
|
// MARK: - Location Search Sheet
|
||||||
|
|
||||||
struct LocationSearchSheet: View {
|
struct LocationSearchSheet: View {
|
||||||
|
|||||||
Reference in New Issue
Block a user