Files
Sportstime/docs/plans/2026-01-12-progress-tracking-enhancements-design.md
Trey t 3d40145ffb docs: update planning documents and todos
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 13:16:52 -06:00

1425 lines
42 KiB
Markdown

# Progress Tracking Enhancements Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Enhance the stadium bucket list experience with multiple visits per stadium display, a dedicated games history view, and an interactive zoomable map.
**Architecture:** Extends the existing Progress feature module with new views (GamesHistoryView) and modifications to existing views (ProgressMapView, VisitDetailView, StadiumVisitSheet). Uses existing SwiftData models (StadiumVisit already supports multiple visits). Adds computed properties to ProgressViewModel for grouping data.
**Tech Stack:** SwiftUI, SwiftData, MapKit
---
## Phase 1: Zoomable Map
### Task 1: Create ProgressMapView Interaction Tests
**Files:**
- Create: `SportsTimeTests/Features/Progress/ProgressMapViewTests.swift`
**Step 1: Write the test file**
```swift
import XCTest
import MapKit
@testable import SportsTime
final class ProgressMapViewTests: XCTestCase {
func test_MapViewModel_TracksUserInteraction() {
// Given: A map view model
let viewModel = MapInteractionViewModel()
// When: User interacts with map (zoom/pan)
viewModel.userDidInteract()
// Then: Interaction is tracked
XCTAssertTrue(viewModel.hasUserInteracted, "Should track user interaction")
XCTAssertTrue(viewModel.shouldShowResetButton, "Should show reset button after interaction")
}
func test_MapViewModel_ResetClearsInteraction() {
// Given: A map with user interaction
let viewModel = MapInteractionViewModel()
viewModel.userDidInteract()
// When: User resets the view
viewModel.resetToDefault()
// Then: Interaction flag is cleared
XCTAssertFalse(viewModel.hasUserInteracted, "Should clear interaction flag after reset")
XCTAssertFalse(viewModel.shouldShowResetButton, "Should hide reset button after reset")
}
func test_MapViewModel_ZoomToStadium_SetsCorrectRegion() {
// Given: A map view model
let viewModel = MapInteractionViewModel()
// When: Zooming to a stadium location
let stadiumCoord = CLLocationCoordinate2D(latitude: 40.8296, longitude: -73.9262) // Yankee Stadium
viewModel.zoomToStadium(at: stadiumCoord)
// Then: Region is set to city-level zoom
XCTAssertEqual(viewModel.region.center.latitude, stadiumCoord.latitude, accuracy: 0.001)
XCTAssertEqual(viewModel.region.center.longitude, stadiumCoord.longitude, accuracy: 0.001)
XCTAssertEqual(viewModel.region.span.latitudeDelta, 0.01, accuracy: 0.005, "Should use city-level zoom span")
}
}
```
**Step 2: Run test to verify it fails**
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/ProgressMapViewTests test 2>&1 | tail -20`
Expected: FAIL with "Cannot find 'MapInteractionViewModel' in scope"
**Step 3: Commit test file**
```bash
git add SportsTimeTests/Features/Progress/ProgressMapViewTests.swift
git commit -m "test: add ProgressMapView interaction tests"
```
---
### Task 2: Create MapInteractionViewModel
**Files:**
- Create: `SportsTime/Features/Progress/ViewModels/MapInteractionViewModel.swift`
**Step 1: Create the view model**
```swift
import SwiftUI
import MapKit
@MainActor
@Observable
final class MapInteractionViewModel {
// Default region: Continental US
static let defaultRegion = MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: 39.8283, longitude: -98.5795),
span: MKCoordinateSpan(latitudeDelta: 35, longitudeDelta: 55)
)
// City-level zoom span
static let stadiumZoomSpan = MKCoordinateSpan(latitudeDelta: 0.01, longitudeDelta: 0.01)
var region: MKCoordinateRegion = MapInteractionViewModel.defaultRegion
var hasUserInteracted: Bool = false
var selectedStadiumId: String?
var shouldShowResetButton: Bool {
hasUserInteracted
}
func userDidInteract() {
hasUserInteracted = true
}
func resetToDefault() {
withAnimation(.easeInOut(duration: 0.5)) {
region = MapInteractionViewModel.defaultRegion
}
hasUserInteracted = false
selectedStadiumId = nil
}
func zoomToStadium(at coordinate: CLLocationCoordinate2D) {
withAnimation(.easeInOut(duration: 0.3)) {
region = MKCoordinateRegion(
center: coordinate,
span: MapInteractionViewModel.stadiumZoomSpan
)
}
hasUserInteracted = true
}
func selectStadium(id: String, coordinate: CLLocationCoordinate2D) {
selectedStadiumId = id
zoomToStadium(at: coordinate)
}
func deselectStadium() {
selectedStadiumId = nil
}
}
```
**Step 2: Run tests to verify they pass**
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/ProgressMapViewTests test 2>&1 | tail -20`
Expected: PASS
**Step 3: Commit view model**
```bash
git add SportsTime/Features/Progress/ViewModels/MapInteractionViewModel.swift
git commit -m "feat: add MapInteractionViewModel for map interactions"
```
---
### Task 3: Update ProgressMapView to Enable Interactions
**Files:**
- Modify: `SportsTime/Features/Progress/Views/ProgressMapView.swift`
**Step 1: Read the current file**
Read `SportsTime/Features/Progress/Views/ProgressMapView.swift` to understand its structure.
**Step 2: Enable map interactions and add reset button**
Update the Map view to:
1. Replace `interactionModes: []` with `interactionModes: [.zoom, .pan]`
2. Add `@State private var mapViewModel = MapInteractionViewModel()`
3. Add `@Binding var region` to track map region
4. Add `onMapCameraChange` handler to detect user interaction
5. Add floating "Reset View" button overlay
The Map should look something like:
```swift
Map(position: $cameraPosition, interactionModes: [.zoom, .pan]) {
// ... existing annotations ...
}
.onMapCameraChange { context in
mapViewModel.userDidInteract()
}
.overlay(alignment: .bottomTrailing) {
if mapViewModel.shouldShowResetButton {
Button {
withAnimation {
cameraPosition = .region(MapInteractionViewModel.defaultRegion)
mapViewModel.resetToDefault()
}
} label: {
Image(systemName: "arrow.counterclockwise")
.font(.title3)
.padding(12)
.background(.regularMaterial, in: Circle())
}
.padding()
}
}
```
**Step 3: Update annotation tap handling**
When tapping a stadium pin, call `mapViewModel.selectStadium(id:coordinate:)` to zoom and select.
**Step 4: Verify build succeeds**
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20`
Expected: BUILD SUCCEEDED
**Step 5: Commit changes**
```bash
git add SportsTime/Features/Progress/Views/ProgressMapView.swift
git commit -m "feat: enable zoom/pan on progress map with reset button"
```
---
## Phase 2: Multiple Visits Per Stadium
### Task 4: Create Visit List Tests
**Files:**
- Create: `SportsTimeTests/Features/Progress/VisitListTests.swift`
**Step 1: Write tests for multiple visits display**
```swift
import XCTest
import SwiftData
@testable import SportsTime
@MainActor
final class VisitListTests: XCTestCase {
var modelContainer: ModelContainer!
var modelContext: ModelContext!
override func setUp() async throws {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
modelContainer = try ModelContainer(
for: StadiumVisit.self, Achievement.self, UserPreferences.self,
configurations: config
)
modelContext = modelContainer.mainContext
}
override func tearDown() async throws {
modelContainer = nil
modelContext = nil
}
func test_VisitsForStadium_ReturnsAllVisitsSortedByDate() async throws {
// Given: Multiple visits to the same stadium
let stadiumId = "yankee-stadium"
let visit1 = StadiumVisit(
stadiumId: stadiumId,
stadiumNameAtVisit: "Yankee Stadium",
visitDate: Date().addingTimeInterval(-86400 * 30), // 30 days ago
visitType: .game,
dataSource: .manual
)
let visit2 = StadiumVisit(
stadiumId: stadiumId,
stadiumNameAtVisit: "Yankee Stadium",
visitDate: Date().addingTimeInterval(-86400 * 7), // 7 days ago
visitType: .game,
dataSource: .manual
)
let visit3 = StadiumVisit(
stadiumId: stadiumId,
stadiumNameAtVisit: "Yankee Stadium",
visitDate: Date(), // today
visitType: .tour,
dataSource: .manual
)
modelContext.insert(visit1)
modelContext.insert(visit2)
modelContext.insert(visit3)
try modelContext.save()
// When: Fetching visits for that stadium
var descriptor = FetchDescriptor<StadiumVisit>(
predicate: #Predicate { $0.stadiumId == stadiumId },
sortBy: [SortDescriptor(\.visitDate, order: .reverse)]
)
let visits = try modelContext.fetch(descriptor)
// Then: All visits returned, most recent first
XCTAssertEqual(visits.count, 3, "Should return all 3 visits")
XCTAssertEqual(visits[0].visitType, .tour, "Most recent visit should be first")
XCTAssertEqual(visits[2].visitType, .game, "Oldest visit should be last")
}
func test_VisitCountForStadium_ReturnsCorrectCount() async throws {
// Given: 3 visits to one stadium, 1 to another
let stadium1 = "yankee-stadium"
let stadium2 = "fenway-park"
for i in 0..<3 {
let visit = StadiumVisit(
stadiumId: stadium1,
stadiumNameAtVisit: "Yankee Stadium",
visitDate: Date().addingTimeInterval(Double(-i * 86400)),
visitType: .game,
dataSource: .manual
)
modelContext.insert(visit)
}
let fenwayVisit = StadiumVisit(
stadiumId: stadium2,
stadiumNameAtVisit: "Fenway Park",
visitDate: Date(),
visitType: .game,
dataSource: .manual
)
modelContext.insert(fenwayVisit)
try modelContext.save()
// When: Counting visits per stadium
let yankeeDescriptor = FetchDescriptor<StadiumVisit>(
predicate: #Predicate { $0.stadiumId == stadium1 }
)
let fenwayDescriptor = FetchDescriptor<StadiumVisit>(
predicate: #Predicate { $0.stadiumId == stadium2 }
)
let yankeeCount = try modelContext.fetchCount(yankeeDescriptor)
let fenwayCount = try modelContext.fetchCount(fenwayDescriptor)
// Then: Correct counts
XCTAssertEqual(yankeeCount, 3)
XCTAssertEqual(fenwayCount, 1)
}
}
```
**Step 2: Run tests to verify they pass**
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/VisitListTests test 2>&1 | tail -20`
Expected: PASS (tests existing functionality)
**Step 3: Commit tests**
```bash
git add SportsTimeTests/Features/Progress/VisitListTests.swift
git commit -m "test: add tests for multiple visits per stadium"
```
---
### Task 5: Create VisitListCard Component
**Files:**
- Create: `SportsTime/Features/Progress/Views/Components/VisitListCard.swift`
**Step 1: Create compact visit card for lists**
```swift
import SwiftUI
struct VisitListCard: View {
let visit: StadiumVisit
@State private var isExpanded = false
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Header row (always visible)
Button {
withAnimation(.easeInOut(duration: 0.2)) {
isExpanded.toggle()
}
} label: {
HStack {
// Date
VStack(alignment: .leading, spacing: 2) {
Text(visit.visitDate.formatted(date: .abbreviated, time: .omitted))
.font(.subheadline.bold())
Text(visit.visitType.rawValue.capitalized)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
// Game info preview (if available)
if let gameInfo = visit.gameInfo {
Text("\(gameInfo.awayTeamName) @ \(gameInfo.homeTeamName)")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
// Chevron
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.secondary)
.rotationEffect(.degrees(isExpanded ? 90 : 0))
}
.padding()
.contentShape(Rectangle())
}
.buttonStyle(.plain)
// Expanded content
if isExpanded {
Divider()
.padding(.horizontal)
VStack(alignment: .leading, spacing: 12) {
// Game details
if let gameInfo = visit.gameInfo {
GameInfoRow(gameInfo: gameInfo)
}
// Seat location
if let seat = visit.seatLocation, !seat.isEmpty {
InfoRow(icon: "seat.airdrop", label: "Seat", value: seat)
}
// Notes
if let notes = visit.notes, !notes.isEmpty {
InfoRow(icon: "note.text", label: "Notes", value: notes)
}
// Photos count
if !visit.photos.isEmpty {
InfoRow(
icon: "photo.on.rectangle",
label: "Photos",
value: "\(visit.photos.count) photo\(visit.photos.count == 1 ? "" : "s")"
)
}
}
.padding()
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
private struct GameInfoRow: View {
let gameInfo: StadiumVisit.GameInfo
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("\(gameInfo.awayTeamName) @ \(gameInfo.homeTeamName)")
.font(.subheadline.bold())
if let score = gameInfo.finalScore {
Text("Final: \(score)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
}
}
}
private struct InfoRow: View {
let icon: String
let label: String
let value: String
var body: some View {
HStack(spacing: 8) {
Image(systemName: icon)
.font(.caption)
.foregroundStyle(.secondary)
.frame(width: 16)
Text(label)
.font(.caption)
.foregroundStyle(.secondary)
Text(value)
.font(.caption)
.lineLimit(2)
}
}
}
#Preview {
let visit = StadiumVisit(
stadiumId: "yankee-stadium",
stadiumNameAtVisit: "Yankee Stadium",
visitDate: Date(),
visitType: .game,
dataSource: .manual
)
visit.seatLocation = "Section 203, Row A, Seat 5"
visit.notes = "Great game! Saw a home run."
return VStack {
VisitListCard(visit: visit)
}
.padding()
.background(Color(.systemGroupedBackground))
}
```
**Step 2: Verify build succeeds**
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20`
Expected: BUILD SUCCEEDED
**Step 3: Commit component**
```bash
git add SportsTime/Features/Progress/Views/Components/VisitListCard.swift
git commit -m "feat: add VisitListCard component for visit history"
```
---
### Task 6: Create StadiumVisitHistoryView
**Files:**
- Create: `SportsTime/Features/Progress/Views/StadiumVisitHistoryView.swift`
**Step 1: Create the visit history view**
```swift
import SwiftUI
import SwiftData
struct StadiumVisitHistoryView: View {
let stadium: Stadium
@Environment(\.modelContext) private var modelContext
@Environment(\.dismiss) private var dismiss
@State private var visits: [StadiumVisit] = []
@State private var isLoading = true
@State private var showingAddVisit = false
var body: some View {
NavigationStack {
Group {
if isLoading {
ProgressView()
} else if visits.isEmpty {
EmptyVisitHistoryView()
} else {
VisitHistoryList(visits: visits)
}
}
.navigationTitle(stadium.name)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done") { dismiss() }
}
ToolbarItem(placement: .primaryAction) {
Button {
showingAddVisit = true
} label: {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $showingAddVisit) {
StadiumVisitSheet(preselectedStadium: stadium)
}
}
.task {
await loadVisits()
}
}
private func loadVisits() async {
isLoading = true
defer { isLoading = false }
let stadiumId = stadium.id
var descriptor = FetchDescriptor<StadiumVisit>(
predicate: #Predicate { $0.stadiumId == stadiumId },
sortBy: [SortDescriptor(\.visitDate, order: .reverse)]
)
do {
visits = try modelContext.fetch(descriptor)
} catch {
visits = []
}
}
}
private struct VisitHistoryList: View {
let visits: [StadiumVisit]
var body: some View {
ScrollView {
VStack(spacing: 12) {
// Visit count header
HStack {
Text("\(visits.count) Visit\(visits.count == 1 ? "" : "s")")
.font(.headline)
Spacer()
}
.padding(.horizontal)
// Visit cards
ForEach(visits, id: \.id) { visit in
VisitListCard(visit: visit)
.padding(.horizontal)
}
}
.padding(.vertical)
}
.background(Color(.systemGroupedBackground))
}
}
private struct EmptyVisitHistoryView: View {
var body: some View {
VStack(spacing: 16) {
Image(systemName: "calendar.badge.plus")
.font(.system(size: 48))
.foregroundStyle(.secondary)
Text("No visits recorded")
.font(.headline)
Text("Tap + to add your first visit to this stadium")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.padding()
}
}
```
**Step 2: Verify build succeeds**
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20`
Expected: BUILD SUCCEEDED
**Step 3: Commit view**
```bash
git add SportsTime/Features/Progress/Views/StadiumVisitHistoryView.swift
git commit -m "feat: add StadiumVisitHistoryView for multiple visits"
```
---
### Task 7: Add Visit Count Badge to Stadium Cards
**Files:**
- Modify: `SportsTime/Features/Progress/Views/ProgressTabView.swift`
**Step 1: Read ProgressTabView to find stadium card/chip component**
Read the file to identify where visited stadium chips are displayed.
**Step 2: Add visit count badge**
Where visited stadiums are shown as chips, add a badge showing visit count:
```swift
// Example modification to stadium chip
HStack(spacing: 4) {
Text(stadiumName)
.font(.caption)
// Visit count badge (if more than 1)
if visitCount > 1 {
Text("\(visitCount)")
.font(.caption2.bold())
.foregroundStyle(.white)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.orange, in: Capsule())
}
}
```
**Step 3: Update tap action to show history**
When tapping a visited stadium, present `StadiumVisitHistoryView` instead of just the most recent visit.
**Step 4: Verify build succeeds**
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20`
Expected: BUILD SUCCEEDED
**Step 5: Commit changes**
```bash
git add SportsTime/Features/Progress/Views/ProgressTabView.swift
git commit -m "feat: add visit count badges and history view navigation"
```
---
## Phase 3: Games History View
### Task 8: Create GamesHistoryViewModel Test
**Files:**
- Create: `SportsTimeTests/Features/Progress/GamesHistoryViewModelTests.swift`
**Step 1: Write tests for games history**
```swift
import XCTest
import SwiftData
@testable import SportsTime
@MainActor
final class GamesHistoryViewModelTests: XCTestCase {
var modelContainer: ModelContainer!
var modelContext: ModelContext!
override func setUp() async throws {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
modelContainer = try ModelContainer(
for: StadiumVisit.self, Achievement.self, UserPreferences.self,
configurations: config
)
modelContext = modelContainer.mainContext
}
override func tearDown() async throws {
modelContainer = nil
modelContext = nil
}
func test_GamesHistoryViewModel_GroupsVisitsByYear() async throws {
// Given: Visits in different years
let visit2026 = StadiumVisit(
stadiumId: "stadium-1",
stadiumNameAtVisit: "Stadium 2026",
visitDate: Calendar.current.date(from: DateComponents(year: 2026, month: 6, day: 15))!,
visitType: .game,
dataSource: .manual
)
let visit2025 = StadiumVisit(
stadiumId: "stadium-2",
stadiumNameAtVisit: "Stadium 2025",
visitDate: Calendar.current.date(from: DateComponents(year: 2025, month: 6, day: 15))!,
visitType: .game,
dataSource: .manual
)
modelContext.insert(visit2026)
modelContext.insert(visit2025)
try modelContext.save()
// When: Loading games history
let viewModel = GamesHistoryViewModel(modelContext: modelContext)
await viewModel.loadGames()
// Then: Visits are grouped by year
XCTAssertEqual(viewModel.visitsByYear.keys.count, 2, "Should have 2 years")
XCTAssertTrue(viewModel.visitsByYear.keys.contains(2026))
XCTAssertTrue(viewModel.visitsByYear.keys.contains(2025))
}
func test_GamesHistoryViewModel_FiltersBySport() async throws {
// Given: Visits to different sport stadiums
// Note: This requires stadiums in AppDataProvider to map stadiumId sport
let mlbVisit = StadiumVisit(
stadiumId: "yankee-stadium",
stadiumNameAtVisit: "Yankee Stadium",
visitDate: Date(),
visitType: .game,
dataSource: .manual
)
modelContext.insert(mlbVisit)
try modelContext.save()
// When: Filtering by MLB
let viewModel = GamesHistoryViewModel(modelContext: modelContext)
await viewModel.loadGames()
viewModel.selectedSports = [.mlb]
// Then: Only MLB visits shown
let filteredCount = viewModel.filteredVisits.count
XCTAssertGreaterThanOrEqual(filteredCount, 0, "Filter should work without crashing")
}
func test_GamesHistoryViewModel_SortsMostRecentFirst() async throws {
// Given: Visits on different dates
let oldVisit = StadiumVisit(
stadiumId: "stadium-1",
stadiumNameAtVisit: "Old Stadium",
visitDate: Date().addingTimeInterval(-86400 * 30), // 30 days ago
visitType: .game,
dataSource: .manual
)
let newVisit = StadiumVisit(
stadiumId: "stadium-2",
stadiumNameAtVisit: "New Stadium",
visitDate: Date(),
visitType: .game,
dataSource: .manual
)
modelContext.insert(oldVisit)
modelContext.insert(newVisit)
try modelContext.save()
// When: Loading games
let viewModel = GamesHistoryViewModel(modelContext: modelContext)
await viewModel.loadGames()
// Then: Most recent first within year
let visits = viewModel.allVisits
XCTAssertEqual(visits.first?.stadiumNameAtVisit, "New Stadium", "Most recent should be first")
}
}
```
**Step 2: Run tests to verify they fail**
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/GamesHistoryViewModelTests test 2>&1 | tail -20`
Expected: FAIL with "Cannot find 'GamesHistoryViewModel' in scope"
**Step 3: Commit tests**
```bash
git add SportsTimeTests/Features/Progress/GamesHistoryViewModelTests.swift
git commit -m "test: add GamesHistoryViewModel tests"
```
---
### Task 9: Create GamesHistoryViewModel
**Files:**
- Create: `SportsTime/Features/Progress/ViewModels/GamesHistoryViewModel.swift`
**Step 1: Create the view model**
```swift
import SwiftUI
import SwiftData
@MainActor
@Observable
final class GamesHistoryViewModel {
private let modelContext: ModelContext
var allVisits: [StadiumVisit] = []
var selectedSports: Set<Sport> = []
var isLoading = false
var error: String?
// Computed: visits grouped by year
var visitsByYear: [Int: [StadiumVisit]] {
let calendar = Calendar.current
let filtered = filteredVisits
return Dictionary(grouping: filtered) { visit in
calendar.component(.year, from: visit.visitDate)
}
}
// Computed: sorted year keys (descending)
var sortedYears: [Int] {
visitsByYear.keys.sorted(by: >)
}
// Computed: filtered by selected sports
var filteredVisits: [StadiumVisit] {
guard !selectedSports.isEmpty else { return allVisits }
return allVisits.filter { visit in
guard let stadium = AppDataProvider.shared.stadium(byId: visit.stadiumId) else {
return false
}
return selectedSports.contains(stadium.sport)
}
}
// Total count
var totalGamesCount: Int {
filteredVisits.count
}
init(modelContext: ModelContext) {
self.modelContext = modelContext
}
func loadGames() async {
isLoading = true
defer { isLoading = false }
let descriptor = FetchDescriptor<StadiumVisit>(
sortBy: [SortDescriptor(\.visitDate, order: .reverse)]
)
do {
allVisits = try modelContext.fetch(descriptor)
} catch {
self.error = "Failed to load games: \(error.localizedDescription)"
allVisits = []
}
}
func toggleSport(_ sport: Sport) {
if selectedSports.contains(sport) {
selectedSports.remove(sport)
} else {
selectedSports.insert(sport)
}
}
func clearFilters() {
selectedSports.removeAll()
}
}
```
**Step 2: Run tests to verify they pass**
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/GamesHistoryViewModelTests test 2>&1 | tail -20`
Expected: PASS
**Step 3: Commit view model**
```bash
git add SportsTime/Features/Progress/ViewModels/GamesHistoryViewModel.swift
git commit -m "feat: add GamesHistoryViewModel with year grouping and sport filtering"
```
---
### Task 10: Create GamesHistoryRow Component
**Files:**
- Create: `SportsTime/Features/Progress/Views/Components/GamesHistoryRow.swift`
**Step 1: Create the row component**
```swift
import SwiftUI
struct GamesHistoryRow: View {
let visit: StadiumVisit
let stadium: Stadium?
var body: some View {
HStack(spacing: 12) {
// Sport icon
if let stadium {
Image(systemName: sportIcon(for: stadium.sport))
.font(.title3)
.foregroundStyle(stadium.sport.color)
.frame(width: 32)
}
// Visit info
VStack(alignment: .leading, spacing: 4) {
// Date
Text(visit.visitDate.formatted(date: .abbreviated, time: .omitted))
.font(.subheadline.bold())
// Teams (if game)
if let gameInfo = visit.gameInfo {
Text("\(gameInfo.awayTeamName) @ \(gameInfo.homeTeamName)")
.font(.caption)
.foregroundStyle(.secondary)
} else {
Text(visit.stadiumNameAtVisit)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
// Score (if available)
if let gameInfo = visit.gameInfo, let score = gameInfo.finalScore {
Text(score)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
// Chevron
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.tertiary)
}
.padding()
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 10))
}
private func sportIcon(for sport: Sport) -> String {
switch sport {
case .mlb: return "baseball"
case .nba: return "basketball"
case .nhl: return "hockey.puck"
case .nfl: return "football"
case .mls: return "soccerball"
}
}
}
#Preview {
let visit = StadiumVisit(
stadiumId: "yankee-stadium",
stadiumNameAtVisit: "Yankee Stadium",
visitDate: Date(),
visitType: .game,
dataSource: .manual
)
GamesHistoryRow(
visit: visit,
stadium: nil
)
.padding()
.background(Color(.systemGroupedBackground))
}
```
**Step 2: Verify build succeeds**
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20`
Expected: BUILD SUCCEEDED
**Step 3: Commit component**
```bash
git add SportsTime/Features/Progress/Views/Components/GamesHistoryRow.swift
git commit -m "feat: add GamesHistoryRow component"
```
---
### Task 11: Create GamesHistoryView
**Files:**
- Create: `SportsTime/Features/Progress/Views/GamesHistoryView.swift`
**Step 1: Create the games history view**
```swift
import SwiftUI
import SwiftData
struct GamesHistoryView: View {
@Environment(\.modelContext) private var modelContext
@State private var viewModel: GamesHistoryViewModel?
@State private var selectedVisit: StadiumVisit?
var body: some View {
NavigationStack {
Group {
if let viewModel {
GamesHistoryContent(
viewModel: viewModel,
selectedVisit: $selectedVisit
)
} else {
ProgressView("Loading games...")
}
}
.navigationTitle("Games Attended")
.sheet(item: $selectedVisit) { visit in
VisitDetailView(visit: visit)
}
}
.task {
if viewModel == nil {
let vm = GamesHistoryViewModel(modelContext: modelContext)
await vm.loadGames()
viewModel = vm
}
}
}
}
private struct GamesHistoryContent: View {
@Bindable var viewModel: GamesHistoryViewModel
@Binding var selectedVisit: StadiumVisit?
var body: some View {
VStack(spacing: 0) {
// Header with count and filters
VStack(spacing: 12) {
// Total count
HStack {
Text("\(viewModel.totalGamesCount) Games")
.font(.headline)
Spacer()
if !viewModel.selectedSports.isEmpty {
Button("Clear") {
viewModel.clearFilters()
}
.font(.caption)
}
}
// Sport filter chips
SportFilterChips(
selectedSports: viewModel.selectedSports,
onToggle: viewModel.toggleSport
)
}
.padding()
.background(Color(.systemBackground))
Divider()
// Games list grouped by year
if viewModel.filteredVisits.isEmpty {
EmptyGamesView()
} else {
GamesListByYear(
visitsByYear: viewModel.visitsByYear,
sortedYears: viewModel.sortedYears,
onSelect: { visit in
selectedVisit = visit
}
)
}
}
.background(Color(.systemGroupedBackground))
}
}
private struct SportFilterChips: View {
let selectedSports: Set<Sport>
let onToggle: (Sport) -> Void
private let sports: [Sport] = [.mlb, .nba, .nhl]
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(sports, id: \.self) { sport in
SportChip(
sport: sport,
isSelected: selectedSports.contains(sport),
onTap: { onToggle(sport) }
)
}
}
}
}
}
private struct SportChip: View {
let sport: Sport
let isSelected: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack(spacing: 4) {
Image(systemName: sportIcon)
.font(.caption)
Text(sport.abbreviation)
.font(.caption.bold())
}
.foregroundStyle(isSelected ? .white : .primary)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(
Capsule()
.fill(isSelected ? sport.color : Color(.systemGray5))
)
}
.buttonStyle(.plain)
}
private var sportIcon: String {
switch sport {
case .mlb: return "baseball"
case .nba: return "basketball"
case .nhl: return "hockey.puck"
case .nfl: return "football"
case .mls: return "soccerball"
}
}
}
private struct GamesListByYear: View {
let visitsByYear: [Int: [StadiumVisit]]
let sortedYears: [Int]
let onSelect: (StadiumVisit) -> Void
var body: some View {
ScrollView {
LazyVStack(spacing: 0, pinnedViews: .sectionHeaders) {
ForEach(sortedYears, id: \.self) { year in
Section {
VStack(spacing: 8) {
ForEach(visitsByYear[year] ?? [], id: \.id) { visit in
Button {
onSelect(visit)
} label: {
GamesHistoryRow(
visit: visit,
stadium: AppDataProvider.shared.stadium(byId: visit.stadiumId)
)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal)
.padding(.vertical, 8)
} header: {
YearHeader(year: year)
}
}
}
}
}
}
private struct YearHeader: View {
let year: Int
var body: some View {
HStack {
Text(String(year))
.font(.title3.bold())
Spacer()
}
.padding(.horizontal)
.padding(.vertical, 8)
.background(Color(.systemGroupedBackground))
}
}
private struct EmptyGamesView: View {
var body: some View {
VStack(spacing: 16) {
Image(systemName: "ticket")
.font(.system(size: 48))
.foregroundStyle(.secondary)
Text("No games recorded yet")
.font(.headline)
Text("Add your first stadium visit to see it here!")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
}
}
```
**Step 2: Verify build succeeds**
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20`
Expected: BUILD SUCCEEDED
**Step 3: Commit view**
```bash
git add SportsTime/Features/Progress/Views/GamesHistoryView.swift
git commit -m "feat: add GamesHistoryView with year grouping and sport filters"
```
---
### Task 12: Add Navigation to GamesHistoryView
**Files:**
- Modify: `SportsTime/Features/Progress/Views/ProgressTabView.swift`
**Step 1: Read ProgressTabView**
Read the file to find the header area and "Recent Visits" section.
**Step 2: Add navigation links**
Add a button in the header or toolbar to access GamesHistoryView:
```swift
// In toolbar or header
NavigationLink(destination: GamesHistoryView()) {
Label("All Games", systemImage: "list.bullet")
}
// Or a "See All" link in Recent Visits section
HStack {
Text("Recent Visits")
.font(.headline)
Spacer()
NavigationLink("See All") {
GamesHistoryView()
}
.font(.caption)
}
```
**Step 3: Verify build succeeds**
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20`
Expected: BUILD SUCCEEDED
**Step 4: Commit changes**
```bash
git add SportsTime/Features/Progress/Views/ProgressTabView.swift
git commit -m "feat: add navigation to GamesHistoryView from Progress tab"
```
---
## Phase 4: Final Integration and Testing
### Task 13: Run Full Test Suite
**Step 1: Run all tests**
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' test 2>&1 | tail -50`
Expected: All tests pass
**Step 2: Fix any failures**
If any tests fail, investigate and fix them:
```bash
# If fixes needed
git add -A
git commit -m "fix: address test failures in progress tracking enhancements"
```
---
### Task 14: Verify Build and Commit Feature
**Step 1: Final build verification**
Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -20`
Expected: BUILD SUCCEEDED
**Step 2: Final commit**
```bash
git add -A
git commit -m "feat: complete progress tracking enhancements
- Enable zoom/pan on progress map with reset button
- Add multiple visits per stadium display with visit count badges
- Create GamesHistoryView with year grouping and sport filters
- Add StadiumVisitHistoryView for viewing all visits to a stadium
- Add VisitListCard and GamesHistoryRow components"
```
---
## Summary
This plan implements three features:
1. **Zoomable Map** (Tasks 1-3)
- Enable zoom/pan interactions on ProgressMapView
- Add MapInteractionViewModel for state management
- Add floating "Reset View" button
2. **Multiple Visits Per Stadium** (Tasks 4-7)
- Create VisitListCard expandable component
- Create StadiumVisitHistoryView for full visit history
- Add visit count badges to stadium chips
- Update tap handling to show history view
3. **Games History View** (Tasks 8-12)
- Create GamesHistoryViewModel with year grouping
- Create GamesHistoryRow component
- Create GamesHistoryView with sport filter chips
- Add navigation from Progress tab
**Files Created:**
- `SportsTime/Features/Progress/ViewModels/MapInteractionViewModel.swift`
- `SportsTime/Features/Progress/Views/Components/VisitListCard.swift`
- `SportsTime/Features/Progress/Views/StadiumVisitHistoryView.swift`
- `SportsTime/Features/Progress/ViewModels/GamesHistoryViewModel.swift`
- `SportsTime/Features/Progress/Views/Components/GamesHistoryRow.swift`
- `SportsTime/Features/Progress/Views/GamesHistoryView.swift`
**Files Modified:**
- `SportsTime/Features/Progress/Views/ProgressMapView.swift`
- `SportsTime/Features/Progress/Views/ProgressTabView.swift`
**Tests Created:**
- `SportsTimeTests/Features/Progress/ProgressMapViewTests.swift`
- `SportsTimeTests/Features/Progress/VisitListTests.swift`
- `SportsTimeTests/Features/Progress/GamesHistoryViewModelTests.swift`