import SwiftUI struct AnalyticsView: View { @EnvironmentObject var dataManager: ClimbingDataManager var body: some View { NavigationView { ScrollView { LazyVStack(spacing: 20) { OverallStatsSection() ProgressChartSection() HStack(spacing: 16) { FavoriteGymSection() RecentActivitySection() } } .padding() } .navigationTitle("Analytics") } } } struct OverallStatsSection: View { @EnvironmentObject var dataManager: ClimbingDataManager var body: some View { VStack(alignment: .leading, spacing: 16) { Text("Overall Stats") .font(.title2) .fontWeight(.bold) LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 2), spacing: 16) { StatCard( title: "Sessions", value: "\(dataManager.completedSessions().count)", icon: "play.fill", color: .blue ) StatCard( title: "Problems", value: "\(dataManager.problems.count)", icon: "star.fill", color: .orange ) StatCard( title: "Attempts", value: "\(dataManager.totalAttempts())", icon: "hand.raised.fill", color: .green ) StatCard( title: "Gyms", value: "\(dataManager.gyms.count)", icon: "location.fill", color: .purple ) } } .padding() .background( RoundedRectangle(cornerRadius: 16) .fill(.regularMaterial) ) } } struct StatCard: View { let title: String let value: String let icon: String let color: Color var body: some View { VStack(spacing: 8) { Image(systemName: icon) .font(.title2) .foregroundColor(color) Text(value) .font(.title) .fontWeight(.bold) .foregroundColor(.primary) Text(title) .font(.caption) .foregroundColor(.secondary) } .frame(maxWidth: .infinity) .padding() .background( RoundedRectangle(cornerRadius: 12) .fill(.ultraThinMaterial) ) } } struct ProgressChartSection: View { @EnvironmentObject var dataManager: ClimbingDataManager @State private var selectedSystem: DifficultySystem = .vScale @State private var showAllTime: Bool = true private var gradeCountData: [GradeCount] { calculateGradeCounts() } private var usedSystems: [DifficultySystem] { let uniqueSystems = Set(gradeCountData.map { $0.difficultySystem }) return uniqueSystems.sorted { let order: [DifficultySystem] = [.vScale, .font, .yds, .custom] let firstIndex = order.firstIndex(of: $0) ?? order.count let secondIndex = order.firstIndex(of: $1) ?? order.count return firstIndex < secondIndex } } var body: some View { VStack(alignment: .leading, spacing: 16) { Text("Grade Distribution") .font(.title2) .fontWeight(.bold) // Toggles section HStack { // Time period toggle HStack(spacing: 8) { Button(action: { showAllTime = true }) { Text("All Time") .font(.caption) .fontWeight(.medium) .padding(.horizontal, 8) .padding(.vertical, 4) .background( RoundedRectangle(cornerRadius: 6) .fill(showAllTime ? .blue : .clear) .stroke(.blue.opacity(0.3), lineWidth: 1) ) .foregroundColor(showAllTime ? .white : .blue) } Button(action: { showAllTime = false }) { Text("7 Days") .font(.caption) .fontWeight(.medium) .padding(.horizontal, 8) .padding(.vertical, 4) .background( RoundedRectangle(cornerRadius: 6) .fill(!showAllTime ? .blue : .clear) .stroke(.blue.opacity(0.3), lineWidth: 1) ) .foregroundColor(!showAllTime ? .white : .blue) } } Spacer() // Scale selector (only show if multiple systems) if usedSystems.count > 1 { Menu { ForEach(usedSystems, id: \.self) { system in Button(action: { selectedSystem = system }) { HStack { Text(system.displayName) if selectedSystem == system { Spacer() Image(systemName: "checkmark") .foregroundColor(.blue) } } } } } label: { HStack(spacing: 4) { Text(selectedSystem.displayName) .font(.subheadline) .fontWeight(.medium) Image(systemName: "chevron.down") .font(.caption) } .padding(.horizontal, 12) .padding(.vertical, 6) .background( RoundedRectangle(cornerRadius: 8) .fill(.blue.opacity(0.1)) .stroke(.blue.opacity(0.3), lineWidth: 1) ) .foregroundColor(.blue) } } } let filteredData = gradeCountData.filter { $0.difficultySystem == selectedSystem } if !filteredData.isEmpty { BarChartView(data: filteredData) .frame(height: 200) Text("Successful climbs by grade") .font(.caption) .foregroundColor(.secondary) } else { VStack(spacing: 8) { Image(systemName: "chart.bar") .font(.title) .foregroundColor(.secondary) Text("No data available for \(selectedSystem.displayName) system") .font(.subheadline) .foregroundColor(.secondary) } .frame(height: 200) .frame(maxWidth: .infinity) } } .padding() .background( RoundedRectangle(cornerRadius: 16) .fill(.regularMaterial) ) .onAppear { if let firstSystem = usedSystems.first { selectedSystem = firstSystem } } } private func calculateGradeCounts() -> [GradeCount] { let problems = dataManager.problems let attempts = dataManager.attempts // Filter attempts by time period let filteredAttempts: [Attempt] if showAllTime { filteredAttempts = attempts.filter { $0.result.isSuccessful } } else { let sevenDaysAgo = Calendar.current.date(byAdding: .day, value: -7, to: Date()) ?? Date() filteredAttempts = attempts.filter { $0.result.isSuccessful && $0.timestamp >= sevenDaysAgo } } // Get attempted problems let attemptedProblemIds = filteredAttempts.map { $0.problemId } let attemptedProblems = problems.filter { attemptedProblemIds.contains($0.id) } // Group by difficulty system and grade var gradeCounts: [String: GradeCount] = [:] for problem in attemptedProblems { let successfulAttemptsForProblem = filteredAttempts.filter { $0.problemId == problem.id } let count = successfulAttemptsForProblem.count let key = "\(problem.difficulty.system.rawValue)-\(problem.difficulty.grade)" if let existing = gradeCounts[key] { gradeCounts[key] = GradeCount( grade: existing.grade, count: existing.count + count, gradeNumeric: existing.gradeNumeric, difficultySystem: existing.difficultySystem ) } else { gradeCounts[key] = GradeCount( grade: problem.difficulty.grade, count: count, gradeNumeric: problem.difficulty.numericValue, difficultySystem: problem.difficulty.system ) } } return Array(gradeCounts.values) } } struct GradeCount { let grade: String let count: Int let gradeNumeric: Int let difficultySystem: DifficultySystem } struct BarChartView: View { let data: [GradeCount] private var sortedData: [GradeCount] { data.sorted { $0.gradeNumeric < $1.gradeNumeric } } private var maxCount: Int { data.map { $0.count }.max() ?? 1 } var body: some View { GeometryReader { geometry in let chartWidth = geometry.size.width - 40 let chartHeight = geometry.size.height - 40 let barWidth = chartWidth / CGFloat(max(sortedData.count, 1)) * 0.8 let spacing = chartWidth / CGFloat(max(sortedData.count, 1)) * 0.2 if sortedData.isEmpty { Rectangle() .fill(.clear) .overlay( Text("No data") .foregroundColor(.secondary) ) } else { VStack(alignment: .leading) { // Chart area HStack(alignment: .bottom, spacing: spacing / CGFloat(sortedData.count)) { ForEach(Array(sortedData.enumerated()), id: \.offset) { index, gradeCount in VStack(spacing: 4) { // Bar RoundedRectangle(cornerRadius: 4) .fill(.blue) .frame( width: barWidth, height: CGFloat(gradeCount.count) / CGFloat(maxCount) * chartHeight * 0.8 ) .overlay( Text("\(gradeCount.count)") .font(.caption2) .fontWeight(.medium) .foregroundColor(.white) .opacity(gradeCount.count > 0 ? 1 : 0) ) // Grade label Text(gradeCount.grade) .font(.caption2) .foregroundColor(.secondary) .lineLimit(1) } } } .frame(height: chartHeight) } .frame(maxWidth: .infinity, alignment: .center) } } } } struct FavoriteGymSection: View { @EnvironmentObject var dataManager: ClimbingDataManager private var favoriteGymInfo: (gym: Gym, sessionCount: Int)? { let gymSessionCounts = Dictionary(grouping: dataManager.sessions, by: { $0.gymId }) .mapValues { $0.count } guard let mostUsedGymId = gymSessionCounts.max(by: { $0.value < $1.value })?.key, let gym = dataManager.gym(withId: mostUsedGymId) else { return nil } return (gym, gymSessionCounts[mostUsedGymId] ?? 0) } var body: some View { VStack(alignment: .leading, spacing: 16) { HStack { Image(systemName: "location.fill") .font(.title2) .foregroundColor(.purple) Text("Favorite Gym") .font(.title2) .fontWeight(.bold) Spacer() } if let info = favoriteGymInfo { VStack(alignment: .leading, spacing: 12) { Text(info.gym.name) .font(.title3) .fontWeight(.semibold) .foregroundColor(.primary) HStack { Image(systemName: "calendar") .font(.subheadline) .foregroundColor(.purple) Text("\(info.sessionCount) sessions") .font(.subheadline) .foregroundColor(.secondary) } Spacer() } } else { VStack(alignment: .leading, spacing: 8) { Text("No sessions yet") .font(.subheadline) .foregroundColor(.secondary) Text("Start climbing to see your favorite gym!") .font(.caption) .foregroundColor(.secondary) Spacer() } } } .frame(maxWidth: .infinity, minHeight: 120, alignment: .topLeading) .padding() .background( RoundedRectangle(cornerRadius: 16) .fill(.regularMaterial) ) } } struct RecentActivitySection: View { @EnvironmentObject var dataManager: ClimbingDataManager private var recentSessionsCount: Int { dataManager.sessions.count } private var totalAttempts: Int { dataManager.attempts.count } var body: some View { VStack(alignment: .leading, spacing: 16) { HStack { Image(systemName: "clock.fill") .font(.title2) .foregroundColor(.blue) Text("Recent Activity") .font(.title2) .fontWeight(.bold) Spacer() } if recentSessionsCount > 0 { VStack(alignment: .leading, spacing: 12) { HStack { Image(systemName: "play.circle") .font(.subheadline) .foregroundColor(.blue) Text("\(recentSessionsCount) sessions") .font(.subheadline) .foregroundColor(.secondary) } HStack { Image(systemName: "hand.raised") .font(.subheadline) .foregroundColor(.green) Text("\(totalAttempts) attempts") .font(.subheadline) .foregroundColor(.secondary) } Spacer() } } else { VStack(alignment: .leading, spacing: 8) { Text("No recent activity") .font(.subheadline) .foregroundColor(.secondary) Text("Start your first session!") .font(.caption) .foregroundColor(.secondary) Spacer() } } } .frame(maxWidth: .infinity, minHeight: 120, alignment: .topLeading) .padding() .background( RoundedRectangle(cornerRadius: 16) .fill(.regularMaterial) ) } } #Preview { AnalyticsView() .environmentObject(ClimbingDataManager.preview) }