// // AnalyticsView.swift // OpenClimb // // Created by OpenClimb on 2025-01-17. // import SwiftUI struct AnalyticsView: View { @EnvironmentObject var dataManager: ClimbingDataManager var body: some View { NavigationView { ScrollView { LazyVStack(spacing: 20) { HeaderSection() OverallStatsSection() ProgressChartSection() FavoriteGymSection() RecentActivitySection() } .padding() } .navigationTitle("Analytics") } } } struct HeaderSection: View { var body: some View { HStack { Image(systemName: "mountain.2.fill") .font(.title) .foregroundColor(.blue) Text("Analytics") .font(.title) .fontWeight(.bold) Spacer() } } } 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 private var progressData: [ProgressDataPoint] { calculateProgressOverTime() } private var usedSystems: [DifficultySystem] { Set(progressData.map { $0.difficultySystem }).sorted { $0.rawValue < $1.rawValue } } var body: some View { VStack(alignment: .leading, spacing: 16) { HStack { Text("Progress Over Time") .font(.title2) .fontWeight(.bold) Spacer() if usedSystems.count > 1 { Menu { ForEach(usedSystems, id: \.self) { system in Button(system.displayName) { selectedSystem = system } } } label: { Text(selectedSystem.displayName) .font(.caption) .padding(.horizontal, 8) .padding(.vertical, 4) .background( RoundedRectangle(cornerRadius: 8) .fill(.blue.opacity(0.1)) ) .foregroundColor(.blue) } } } let filteredData = progressData.filter { $0.difficultySystem == selectedSystem } if !filteredData.isEmpty { VStack { // Simple text-based chart placeholder VStack(alignment: .leading, spacing: 8) { ForEach(filteredData.indices.prefix(5), id: \.self) { index in let point = filteredData[index] HStack { Text("Session \(index + 1)") .font(.caption) .frame(width: 80, alignment: .leading) Rectangle() .fill(.blue) .frame(width: CGFloat(point.maxGradeNumeric * 5), height: 20) Text(point.maxGrade) .font(.caption) .foregroundColor(.blue) } } if filteredData.count > 5 { Text("... and \(filteredData.count - 5) more sessions") .font(.caption) .foregroundColor(.secondary) } } } .frame(height: 200) Text( "X: session number, Y: max \(selectedSystem.displayName.lowercased()) grade achieved" ) .font(.caption) .foregroundColor(.secondary) } else { VStack(spacing: 8) { Image(systemName: "chart.line.uptrend.xyaxis") .font(.title) .foregroundColor(.secondary) Text("No progress 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 calculateProgressOverTime() -> [ProgressDataPoint] { let sessions = dataManager.completedSessions().sorted { $0.date < $1.date } let problems = dataManager.problems let attempts = dataManager.attempts return sessions.compactMap { session in let sessionAttempts = attempts.filter { $0.sessionId == session.id } let attemptedProblemIds = sessionAttempts.map { $0.problemId } let attemptedProblems = problems.filter { attemptedProblemIds.contains($0.id) } guard let highestGradeProblem = attemptedProblems.max(by: { $0.difficulty.numericValue < $1.difficulty.numericValue }) else { return nil } return ProgressDataPoint( date: session.date, maxGrade: highestGradeProblem.difficulty.grade, maxGradeNumeric: highestGradeProblem.difficulty.numericValue, climbType: highestGradeProblem.climbType, difficultySystem: highestGradeProblem.difficulty.system ) } } } 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: 12) { Text("Favorite Gym") .font(.title2) .fontWeight(.bold) if let info = favoriteGymInfo { VStack(alignment: .leading, spacing: 8) { Text(info.gym.name) .font(.title3) .fontWeight(.semibold) Text("\(info.sessionCount) sessions") .font(.subheadline) .foregroundColor(.secondary) } } else { Text("No sessions yet") .font(.subheadline) .foregroundColor(.secondary) } } .padding() .background( RoundedRectangle(cornerRadius: 16) .fill(.regularMaterial) ) } } struct RecentActivitySection: View { @EnvironmentObject var dataManager: ClimbingDataManager private var recentSessionsCount: Int { dataManager.sessions.count } var body: some View { VStack(alignment: .leading, spacing: 12) { Text("Recent Activity") .font(.title2) .fontWeight(.bold) if recentSessionsCount > 0 { Text("You've had \(recentSessionsCount) sessions") .font(.subheadline) } else { Text("No recent activity") .font(.subheadline) .foregroundColor(.secondary) } } .padding() .background( RoundedRectangle(cornerRadius: 16) .fill(.regularMaterial) ) } } struct ProgressDataPoint { let date: Date let maxGrade: String let maxGradeNumeric: Int let climbType: ClimbType let difficultySystem: DifficultySystem } // MARK: - Helper Functions func gradeToNumeric(_ system: DifficultySystem, _ grade: String) -> Int { switch system { case .vScale: if grade == "VB" { return 0 } return Int(grade.replacingOccurrences(of: "V", with: "")) ?? 0 case .font: let fontMapping: [String: Int] = [ "3": 3, "4A": 4, "4B": 5, "4C": 6, "5A": 7, "5B": 8, "5C": 9, "6A": 10, "6A+": 11, "6B": 12, "6B+": 13, "6C": 14, "6C+": 15, "7A": 16, "7A+": 17, "7B": 18, "7B+": 19, "7C": 20, "7C+": 21, "8A": 22, "8A+": 23, "8B": 24, "8B+": 25, "8C": 26, "8C+": 27, ] return fontMapping[grade] ?? 0 case .yds: let ydsMapping: [String: Int] = [ "5.0": 50, "5.1": 51, "5.2": 52, "5.3": 53, "5.4": 54, "5.5": 55, "5.6": 56, "5.7": 57, "5.8": 58, "5.9": 59, "5.10a": 60, "5.10b": 61, "5.10c": 62, "5.10d": 63, "5.11a": 64, "5.11b": 65, "5.11c": 66, "5.11d": 67, "5.12a": 68, "5.12b": 69, "5.12c": 70, "5.12d": 71, "5.13a": 72, "5.13b": 73, "5.13c": 74, "5.13d": 75, "5.14a": 76, "5.14b": 77, "5.14c": 78, "5.14d": 79, "5.15a": 80, "5.15b": 81, "5.15c": 82, "5.15d": 83, ] return ydsMapping[grade] ?? 0 case .custom: return Int(grade) ?? 0 } } func numericToGrade(_ system: DifficultySystem, _ numeric: Int) -> String { switch system { case .vScale: return numeric == 0 ? "VB" : "V\(numeric)" case .font: let fontMapping: [Int: String] = [ 3: "3", 4: "4A", 5: "4B", 6: "4C", 7: "5A", 8: "5B", 9: "5C", 10: "6A", 11: "6A+", 12: "6B", 13: "6B+", 14: "6C", 15: "6C+", 16: "7A", 17: "7A+", 18: "7B", 19: "7B+", 20: "7C", 21: "7C+", 22: "8A", 23: "8A+", 24: "8B", 25: "8B+", 26: "8C", 27: "8C+", ] return fontMapping[numeric] ?? "\(numeric)" case .yds: let ydsMapping: [Int: String] = [ 50: "5.0", 51: "5.1", 52: "5.2", 53: "5.3", 54: "5.4", 55: "5.5", 56: "5.6", 57: "5.7", 58: "5.8", 59: "5.9", 60: "5.10a", 61: "5.10b", 62: "5.10c", 63: "5.10d", 64: "5.11a", 65: "5.11b", 66: "5.11c", 67: "5.11d", 68: "5.12a", 69: "5.12b", 70: "5.12c", 71: "5.12d", 72: "5.13a", 73: "5.13b", 74: "5.13c", 75: "5.13d", 76: "5.14a", 77: "5.14b", 78: "5.14c", 79: "5.14d", 80: "5.15a", 81: "5.15b", 82: "5.15c", 83: "5.15d", ] return ydsMapping[numeric] ?? "\(numeric)" case .custom: return "\(numeric)" } } #Preview { AnalyticsView() .environmentObject(ClimbingDataManager.preview) }