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:
- Replace
interactionModes: []withinteractionModes: [.zoom, .pan] - Add
@State private var mapViewModel = MapInteractionViewModel() - Add
@Binding var regionto track map region - Add
onMapCameraChangehandler to detect user interaction - 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:
-
Zoomable Map (Tasks 1-3)
- Enable zoom/pan interactions on ProgressMapView
- Add MapInteractionViewModel for state management
- Add floating "Reset View" button
-
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
-
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.swiftSportsTime/Features/Progress/Views/Components/VisitListCard.swiftSportsTime/Features/Progress/Views/StadiumVisitHistoryView.swiftSportsTime/Features/Progress/ViewModels/GamesHistoryViewModel.swiftSportsTime/Features/Progress/Views/Components/GamesHistoryRow.swiftSportsTime/Features/Progress/Views/GamesHistoryView.swift
Files Modified:
SportsTime/Features/Progress/Views/ProgressMapView.swiftSportsTime/Features/Progress/Views/ProgressTabView.swift
Tests Created:
SportsTimeTests/Features/Progress/ProgressMapViewTests.swiftSportsTimeTests/Features/Progress/VisitListTests.swiftSportsTimeTests/Features/Progress/GamesHistoryViewModelTests.swift