// // 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() HStack(spacing: 16) { FavoriteGymSection() RecentActivitySection() } } .padding() } .navigationTitle("Analytics") } } } struct HeaderSection: View { var body: some View { HStack { Image("MountainsIcon") .resizable() .frame(width: 32, height: 32) 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] { let uniqueSystems = Set(progressData.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) { HStack { Text("Progress Over Time") .font(.title2) .fontWeight(.bold) Spacer() 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 = progressData.filter { $0.difficultySystem == selectedSystem } if !filteredData.isEmpty { LineChartView(data: filteredData, selectedSystem: selectedSystem) .frame(height: 200) Text( "Progress: max \(selectedSystem.displayName.lowercased()) grade achieved per session" ) .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) } // Group problems by difficulty system let problemsBySystem = Dictionary(grouping: attemptedProblems) { $0.difficulty.system } // Create data points for each system used in this session return problemsBySystem.compactMap { (system, systemProblems) -> ProgressDataPoint? in guard let highestGradeProblem = systemProblems.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: system ) } }.flatMap { $0 } } } 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) ) } } struct LineChartView: View { let data: [ProgressDataPoint] let selectedSystem: DifficultySystem private var uniqueGrades: [String] { if selectedSystem == .custom { return Array(Set(data.map { $0.maxGrade })).sorted { grade1, grade2 in return (Int(grade1) ?? 0) > (Int(grade2) ?? 0) } } else { return Array(Set(data.map { $0.maxGrade })).sorted { grade1, grade2 in let grade1Data = data.first(where: { $0.maxGrade == grade1 }) let grade2Data = data.first(where: { $0.maxGrade == grade2 }) return (grade1Data?.maxGradeNumeric ?? 0) > (grade2Data?.maxGradeNumeric ?? 0) } } } private var minGrade: Int { data.map { $0.maxGradeNumeric }.min() ?? 0 } private var maxGrade: Int { data.map { $0.maxGradeNumeric }.max() ?? 1 } private var gradeRange: Int { max(maxGrade - minGrade, 1) } var body: some View { GeometryReader { geometry in let chartWidth = geometry.size.width - 40 let chartHeight = geometry.size.height - 40 if data.isEmpty { Rectangle() .fill(.clear) .overlay( Text("No data") .foregroundColor(.secondary) ) } else { HStack { // Y-axis labels VStack { ForEach(0.. 1 { Path { path in for (index, point) in data.enumerated() { let x = CGFloat(index) * chartWidth / CGFloat(data.count - 1) let normalizedY = CGFloat(point.maxGradeNumeric - minGrade) / CGFloat(gradeRange) let y = chartHeight - (normalizedY * chartHeight) if index == 0 { path.move(to: CGPoint(x: x, y: y)) } else { path.addLine(to: CGPoint(x: x, y: y)) } } } .stroke(.blue, lineWidth: 2) } // Data points ForEach(data.indices, id: \.self) { index in let point = data[index] let x = data.count == 1 ? chartWidth / 2 : CGFloat(index) * chartWidth / CGFloat(data.count - 1) let normalizedY = CGFloat(point.maxGradeNumeric - minGrade) / CGFloat(gradeRange) let y = chartHeight - (normalizedY * chartHeight) Circle() .fill(.blue) .frame(width: 8, height: 8) .position(x: x, y: y) .overlay( Circle() .stroke(.white, lineWidth: 2) .frame(width: 8, height: 8) .position(x: x, y: y) ) } } .frame(width: chartWidth, height: chartHeight) } } } .padding() } } struct ProgressDataPoint { let date: Date let maxGrade: String let maxGradeNumeric: Int let climbType: ClimbType let difficultySystem: DifficultySystem } // MARK: - Helper Functions #Preview { AnalyticsView() .environmentObject(ClimbingDataManager.preview) }