diff --git a/SportsTime/Features/Progress/ViewModels/GamesHistoryViewModel.swift b/SportsTime/Features/Progress/ViewModels/GamesHistoryViewModel.swift new file mode 100644 index 0000000..a5c4f8d --- /dev/null +++ b/SportsTime/Features/Progress/ViewModels/GamesHistoryViewModel.swift @@ -0,0 +1,76 @@ +import SwiftUI +import SwiftData + +@MainActor +@Observable +final class GamesHistoryViewModel { + private let modelContext: ModelContext + + var allVisits: [StadiumVisit] = [] + var selectedSports: Set = [] + 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(for: 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( + 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() + } +} diff --git a/SportsTime/Features/Progress/ViewModels/MapInteractionViewModel.swift b/SportsTime/Features/Progress/ViewModels/MapInteractionViewModel.swift new file mode 100644 index 0000000..7d35fb2 --- /dev/null +++ b/SportsTime/Features/Progress/ViewModels/MapInteractionViewModel.swift @@ -0,0 +1,54 @@ +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 + } +} diff --git a/SportsTime/Features/Progress/Views/Components/GamesHistoryRow.swift b/SportsTime/Features/Progress/Views/Components/GamesHistoryRow.swift new file mode 100644 index 0000000..124c598 --- /dev/null +++ b/SportsTime/Features/Progress/Views/Components/GamesHistoryRow.swift @@ -0,0 +1,65 @@ +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.themeColor) + .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 matchup = visit.matchupDescription { + Text(matchup) + .font(.caption) + .foregroundStyle(.secondary) + } else { + Text(visit.stadiumNameAtVisit) + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Spacer() + + // 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" + @unknown default: return "sportscourt" + } + } +} + +#Preview { + VStack { + Text("GamesHistoryRow Preview") + } + .padding() + .background(Color(.systemGroupedBackground)) +} diff --git a/SportsTime/Features/Progress/Views/Components/VisitListCard.swift b/SportsTime/Features/Progress/Views/Components/VisitListCard.swift new file mode 100644 index 0000000..34111b2 --- /dev/null +++ b/SportsTime/Features/Progress/Views/Components/VisitListCard.swift @@ -0,0 +1,136 @@ +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.displayName) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + // Game info preview (if available) + if let matchup = visit.matchupDescription { + Text(matchup) + .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 matchup = visit.matchupDescription { + GameInfoRow(matchup: matchup, score: visit.finalScore) + } + + // Seat location + if let seat = visit.seatLocation, !seat.isEmpty { + InfoRow(icon: "ticket", label: "Seat", value: seat) + } + + // Notes + if let notes = visit.notes, !notes.isEmpty { + InfoRow(icon: "note.text", label: "Notes", value: notes) + } + + // Photos count + if let photos = visit.photoMetadata, !photos.isEmpty { + InfoRow( + icon: "photo.on.rectangle", + label: "Photos", + value: "\(photos.count) photo\(photos.count == 1 ? "" : "s")" + ) + } + } + .padding() + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + .background(Color(.systemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } +} + +private struct GameInfoRow: View { + let matchup: String + let score: String? + + var body: some View { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(matchup) + .font(.subheadline.bold()) + + if let score { + 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 { + VStack { + Text("VisitListCard Preview") + } + .padding() + .background(Color(.systemGroupedBackground)) +} diff --git a/SportsTime/Features/Progress/Views/GamesHistoryView.swift b/SportsTime/Features/Progress/Views/GamesHistoryView.swift new file mode 100644 index 0000000..3a569ff --- /dev/null +++ b/SportsTime/Features/Progress/Views/GamesHistoryView.swift @@ -0,0 +1,210 @@ +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 { + Group { + if let viewModel { + GamesHistoryContent( + viewModel: viewModel, + selectedVisit: $selectedVisit + ) + } else { + ProgressView("Loading games...") + } + } + .navigationTitle("Games Attended") + .sheet(item: $selectedVisit) { visit in + if let stadium = AppDataProvider.shared.stadium(for: visit.stadiumId) { + VisitDetailView(visit: visit, stadium: stadium) + } + } + .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 + 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.rawValue) + .font(.caption.bold()) + } + .foregroundStyle(isSelected ? .white : .primary) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + Capsule() + .fill(isSelected ? sport.themeColor : 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" + @unknown default: return "sportscourt" + } + } +} + +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(for: 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() + } +} diff --git a/SportsTime/Features/Progress/Views/ProgressMapView.swift b/SportsTime/Features/Progress/Views/ProgressMapView.swift index 0bc6484..895acf4 100644 --- a/SportsTime/Features/Progress/Views/ProgressMapView.swift +++ b/SportsTime/Features/Progress/Views/ProgressMapView.swift @@ -15,16 +15,11 @@ struct ProgressMapView: View { let visitStatus: [String: StadiumVisitStatus] @Binding var selectedStadium: Stadium? - // Fixed region for continental US - map is locked to this view - private let usRegion = MKCoordinateRegion( - center: CLLocationCoordinate2D(latitude: 39.8283, longitude: -98.5795), - span: MKCoordinateSpan(latitudeDelta: 50, longitudeDelta: 60) - ) + @State private var mapViewModel = MapInteractionViewModel() + @State private var cameraPosition: MapCameraPosition = .region(MapInteractionViewModel.defaultRegion) var body: some View { - // Use initialPosition with empty interactionModes to disable pan/zoom - // while keeping annotations tappable - Map(initialPosition: .region(usRegion), interactionModes: []) { + Map(position: $cameraPosition, interactionModes: [.zoom, .pan]) { ForEach(stadiums) { stadium in Annotation( stadium.name, @@ -50,6 +45,26 @@ struct ProgressMapView: View { } } } + .onMapCameraChange { _ in + mapViewModel.userDidInteract() + } + .overlay(alignment: .bottomTrailing) { + if mapViewModel.shouldShowResetButton { + Button { + withAnimation(.easeInOut(duration: 0.5)) { + cameraPosition = .region(MapInteractionViewModel.defaultRegion) + mapViewModel.resetToDefault() + selectedStadium = nil + } + } label: { + Image(systemName: "arrow.counterclockwise") + .font(.title3) + .padding(12) + .background(.regularMaterial, in: Circle()) + } + .padding() + } + } .mapStyle(.standard(elevation: .realistic)) .clipShape(RoundedRectangle(cornerRadius: 16)) } diff --git a/SportsTime/Features/Progress/Views/ProgressTabView.swift b/SportsTime/Features/Progress/Views/ProgressTabView.swift index eda05bb..03477bb 100644 --- a/SportsTime/Features/Progress/Views/ProgressTabView.swift +++ b/SportsTime/Features/Progress/Views/ProgressTabView.swift @@ -261,7 +261,8 @@ struct ProgressTabView: View { ForEach(viewModel.visitedStadiums) { stadium in StadiumChip( stadium: stadium, - isVisited: true + isVisited: true, + visitCount: viewModel.stadiumVisitStatus[stadium.id]?.visitCount ?? 1 ) { selectedStadium = stadium } @@ -368,9 +369,24 @@ struct ProgressTabView: View { private var recentVisitsSection: some View { VStack(alignment: .leading, spacing: Theme.Spacing.sm) { - Text("Recent Visits") - .font(.title2) - .foregroundStyle(Theme.textPrimary(colorScheme)) + HStack { + Text("Recent Visits") + .font(.title2) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + Spacer() + + NavigationLink { + GamesHistoryView() + } label: { + HStack(spacing: 4) { + Text("See All") + Image(systemName: "chevron.right") + } + .font(.subheadline) + .foregroundStyle(Theme.warmOrange) + } + } ForEach(viewModel.recentVisits) { visitSummary in if let stadiumVisit = visits.first(where: { $0.id == visitSummary.id }) { @@ -418,6 +434,7 @@ struct ProgressStatPill: View { struct StadiumChip: View { let stadium: Stadium let isVisited: Bool + var visitCount: Int = 1 let action: () -> Void @Environment(\.colorScheme) private var colorScheme @@ -432,10 +449,22 @@ struct StadiumChip: View { } VStack(alignment: .leading, spacing: 2) { - Text(stadium.name) - .font(.subheadline) - .foregroundStyle(Theme.textPrimary(colorScheme)) - .lineLimit(1) + HStack(spacing: 4) { + Text(stadium.name) + .font(.subheadline) + .foregroundStyle(Theme.textPrimary(colorScheme)) + .lineLimit(1) + + // Visit count badge (if more than 1) + if visitCount > 1 { + Text("\(visitCount)") + .font(.caption2.bold()) + .foregroundStyle(.white) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Theme.warmOrange, in: Capsule()) + } + } Text(stadium.city) .font(.caption) diff --git a/SportsTime/Features/Progress/Views/StadiumVisitHistoryView.swift b/SportsTime/Features/Progress/Views/StadiumVisitHistoryView.swift new file mode 100644 index 0000000..8566b11 --- /dev/null +++ b/SportsTime/Features/Progress/Views/StadiumVisitHistoryView.swift @@ -0,0 +1,109 @@ +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(initialStadium: stadium) + } + } + .task { + await loadVisits() + } + } + + private func loadVisits() async { + isLoading = true + defer { isLoading = false } + + let stadiumId = stadium.id + let descriptor = FetchDescriptor( + 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() + } +} diff --git a/SportsTimeTests/Features/Progress/GamesHistoryViewModelTests.swift b/SportsTimeTests/Features/Progress/GamesHistoryViewModelTests.swift new file mode 100644 index 0000000..351e70f --- /dev/null +++ b/SportsTimeTests/Features/Progress/GamesHistoryViewModelTests.swift @@ -0,0 +1,110 @@ +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") + } +} diff --git a/SportsTimeTests/Features/Progress/ProgressMapViewTests.swift b/SportsTimeTests/Features/Progress/ProgressMapViewTests.swift new file mode 100644 index 0000000..8f38555 --- /dev/null +++ b/SportsTimeTests/Features/Progress/ProgressMapViewTests.swift @@ -0,0 +1,48 @@ +import XCTest +import MapKit +@testable import SportsTime + +final class ProgressMapViewTests: XCTestCase { + + @MainActor + 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") + } + + @MainActor + 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") + } + + @MainActor + 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") + } +} diff --git a/SportsTimeTests/Features/Progress/VisitListTests.swift b/SportsTimeTests/Features/Progress/VisitListTests.swift new file mode 100644 index 0000000..2d232d2 --- /dev/null +++ b/SportsTimeTests/Features/Progress/VisitListTests.swift @@ -0,0 +1,111 @@ +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 + let descriptor = FetchDescriptor( + 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( + predicate: #Predicate { $0.stadiumId == stadium1 } + ) + let fenwayDescriptor = FetchDescriptor( + 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) + } +}