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

42 KiB

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

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

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

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

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:

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

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

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

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

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

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

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

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:

// 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

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

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

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

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

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

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

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

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

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:

// 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

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:

# 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

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