DashboardView.swift
1 // 2 // DashboardView.swift 3 // RacerTracer 4 // 5 // Created by Alexander Kunau on 29.09.25. 6 // 7 8 import SwiftUI 9 import Charts 10 11 struct DashboardView: View { 12 @EnvironmentObject var seoService: SEOAnalysisService 13 @State private var selectedTimeRange: TimeRange = .last30Days 14 15 var body: some View { 16 ScrollView { 17 VStack(spacing: 24) { 18 // Header 19 DashboardHeader() 20 21 // Quick Stats Cards 22 StatsCardsView() 23 24 // Charts Section 25 DashboardChartsSection(selectedTimeRange: $selectedTimeRange) 26 27 // Recent Analysis 28 RecentAnalysisSection() 29 30 // Issues Overview 31 IssuesOverviewSection() 32 } 33 .padding(24) 34 } 35 .navigationTitle("SEO Dashboard") 36 } 37 } 38 39 struct DashboardHeader: View { 40 var body: some View { 41 VStack(alignment: .leading, spacing: 8) { 42 HStack { 43 VStack(alignment: .leading) { 44 Text("SEO Dashboard") 45 .font(.largeTitle) 46 .fontWeight(.bold) 47 48 Text("Monitor your website's SEO performance") 49 .font(.title3) 50 .foregroundColor(.secondary) 51 } 52 53 Spacer() 54 55 Button("New Analysis") { 56 // Action here 57 } 58 .buttonStyle(.borderedProminent) 59 } 60 } 61 } 62 } 63 64 struct StatsCardsView: View { 65 @EnvironmentObject var seoService: SEOAnalysisService 66 67 var body: some View { 68 HStack(spacing: 20) { 69 StatsCard( 70 title: "Total Analyses", 71 value: "\(seoService.analysisHistory.count)", 72 icon: "chart.bar.fill", 73 color: .blue 74 ) 75 76 StatsCard( 77 title: "Average Score", 78 value: String(format: "%.0f", averageScore), 79 icon: "star.fill", 80 color: .orange 81 ) 82 83 StatsCard( 84 title: "Critical Issues", 85 value: "\(criticalIssuesCount)", 86 icon: "exclamationmark.triangle.fill", 87 color: .red 88 ) 89 90 StatsCard( 91 title: "Last Analysis", 92 value: lastAnalysisDate, 93 icon: "clock.fill", 94 color: .green 95 ) 96 } 97 } 98 99 private var averageScore: Double { 100 guard !seoService.analysisHistory.isEmpty else { return 0 } 101 let total = seoService.analysisHistory.reduce(0) { $0 + $1.overallScore } 102 return total / Double(seoService.analysisHistory.count) 103 } 104 105 private var criticalIssuesCount: Int { 106 seoService.analysisHistory.flatMap { $0.issues } 107 .filter { $0.severity == .critical }.count 108 } 109 110 private var lastAnalysisDate: String { 111 guard let lastAnalysis = seoService.analysisHistory.last else { return "None" } 112 let formatter = DateFormatter() 113 formatter.dateStyle = .short 114 return formatter.string(from: lastAnalysis.timestamp) 115 } 116 } 117 118 struct StatsCard: View { 119 let title: String 120 let value: String 121 let icon: String 122 let color: Color 123 124 var body: some View { 125 VStack(alignment: .leading, spacing: 12) { 126 HStack { 127 Image(systemName: icon) 128 .foregroundColor(color) 129 .font(.title2) 130 131 Spacer() 132 } 133 134 VStack(alignment: .leading, spacing: 4) { 135 Text(value) 136 .font(.title) 137 .fontWeight(.bold) 138 139 Text(title) 140 .font(.caption) 141 .foregroundColor(.secondary) 142 } 143 } 144 .padding(16) 145 .background(Color(NSColor.controlBackgroundColor)) 146 .cornerRadius(12) 147 .frame(maxWidth: .infinity) 148 } 149 } 150 151 enum TimeRange: String, CaseIterable { 152 case last7Days = "Last 7 Days" 153 case last30Days = "Last 30 Days" 154 case last90Days = "Last 90 Days" 155 } 156 157 struct DashboardChartsSection: View { 158 @Binding var selectedTimeRange: TimeRange 159 @EnvironmentObject var seoService: SEOAnalysisService 160 161 var body: some View { 162 VStack(alignment: .leading, spacing: 16) { 163 HStack { 164 Text("Performance Trends") 165 .font(.title2) 166 .fontWeight(.semibold) 167 168 Spacer() 169 170 Picker("Time Range", selection: $selectedTimeRange) { 171 ForEach(TimeRange.allCases, id: \.self) { range in 172 Text(range.rawValue).tag(range) 173 } 174 } 175 .pickerStyle(SegmentedPickerStyle()) 176 .frame(width: 300) 177 } 178 179 if !seoService.analysisHistory.isEmpty { 180 Chart(filteredAnalysisData) { analysis in 181 LineMark( 182 x: .value("Date", analysis.timestamp), 183 y: .value("Score", analysis.overallScore) 184 ) 185 .foregroundStyle(Color.accentColor) 186 .symbol(Circle()) 187 } 188 .frame(height: 200) 189 .chartYScale(domain: 0...100) 190 .chartXAxis { 191 AxisMarks(values: .stride(by: .day, count: chartDateStride)) { value in 192 AxisValueLabel(format: .dateTime.month(.abbreviated).day()) 193 AxisGridLine() 194 AxisTick() 195 } 196 } 197 .chartYAxis { 198 AxisMarks { value in 199 AxisValueLabel() 200 AxisGridLine() 201 AxisTick() 202 } 203 } 204 } else { 205 VStack(spacing: 16) { 206 Image(systemName: "chart.line.uptrend.xyaxis") 207 .font(.system(size: 48)) 208 .foregroundColor(.secondary) 209 210 Text("No analysis data available") 211 .font(.headline) 212 .foregroundColor(.secondary) 213 214 Text("Start by analyzing a website to see performance trends") 215 .font(.subheadline) 216 .foregroundColor(.secondary) 217 .multilineTextAlignment(.center) 218 } 219 .frame(height: 200) 220 .frame(maxWidth: .infinity) 221 } 222 } 223 .padding(20) 224 .background(Color(NSColor.controlBackgroundColor)) 225 .cornerRadius(12) 226 } 227 228 private var filteredAnalysisData: [SEOAnalysis] { 229 let cutoffDate: Date 230 let calendar = Calendar.current 231 232 switch selectedTimeRange { 233 case .last7Days: 234 cutoffDate = calendar.date(byAdding: .day, value: -7, to: Date()) ?? Date() 235 case .last30Days: 236 cutoffDate = calendar.date(byAdding: .day, value: -30, to: Date()) ?? Date() 237 case .last90Days: 238 cutoffDate = calendar.date(byAdding: .day, value: -90, to: Date()) ?? Date() 239 } 240 241 return seoService.analysisHistory.filter { $0.timestamp >= cutoffDate } 242 } 243 244 private var chartDateStride: Int { 245 switch selectedTimeRange { 246 case .last7Days: 247 return 1 248 case .last30Days: 249 return 7 250 case .last90Days: 251 return 14 252 } 253 } 254 } 255 256 struct RecentAnalysisSection: View { 257 @EnvironmentObject var seoService: SEOAnalysisService 258 259 var body: some View { 260 VStack(alignment: .leading, spacing: 16) { 261 Text("Recent Analyses") 262 .font(.title2) 263 .fontWeight(.semibold) 264 265 if seoService.analysisHistory.isEmpty { 266 EmptyDashboardStateView( 267 icon: "magnifyingglass.circle", 268 title: "No analyses yet", 269 subtitle: "Start analyzing websites to track their SEO performance" 270 ) 271 } else { 272 LazyVStack(spacing: 12) { 273 ForEach(seoService.analysisHistory.suffix(5).reversed(), id: \.id) { analysis in 274 RecentAnalysisRow(analysis: analysis) 275 } 276 } 277 } 278 } 279 .padding(20) 280 .background(Color(NSColor.controlBackgroundColor)) 281 .cornerRadius(12) 282 } 283 } 284 285 struct RecentAnalysisRow: View { 286 let analysis: SEOAnalysis 287 288 var body: some View { 289 HStack(spacing: 16) { 290 // Score indicator 291 ZStack { 292 Circle() 293 .strokeBorder(scoreColor.opacity(0.3), lineWidth: 3) 294 .frame(width: 40, height: 40) 295 296 Circle() 297 .trim(from: 0, to: analysis.overallScore / 100) 298 .stroke(scoreColor, style: StrokeStyle(lineWidth: 3, lineCap: .round)) 299 .frame(width: 40, height: 40) 300 .rotationEffect(.degrees(-90)) 301 302 Text("\(Int(analysis.overallScore))") 303 .font(.caption) 304 .fontWeight(.semibold) 305 } 306 307 VStack(alignment: .leading, spacing: 4) { 308 Text(analysis.url) 309 .font(.headline) 310 .lineLimit(1) 311 312 Text(analysis.timestamp, style: .relative) 313 .font(.caption) 314 .foregroundColor(.secondary) 315 } 316 317 Spacer() 318 319 VStack(alignment: .trailing, spacing: 4) { 320 Text("\(analysis.issues.count) issues") 321 .font(.caption) 322 .foregroundColor(.secondary) 323 324 if analysis.issues.contains(where: { $0.severity == .critical }) { 325 Label("Critical", systemImage: "exclamationmark.triangle.fill") 326 .font(.caption) 327 .foregroundColor(.red) 328 } 329 } 330 } 331 .padding(12) 332 .background(Color(NSColor.textBackgroundColor)) 333 .cornerRadius(8) 334 } 335 336 private var scoreColor: Color { 337 switch analysis.overallScore { 338 case 80...: 339 return .green 340 case 60..<80: 341 return .orange 342 default: 343 return .red 344 } 345 } 346 } 347 348 struct IssuesOverviewSection: View { 349 @EnvironmentObject var seoService: SEOAnalysisService 350 351 var body: some View { 352 VStack(alignment: .leading, spacing: 16) { 353 Text("Issues Overview") 354 .font(.title2) 355 .fontWeight(.semibold) 356 357 if allIssues.isEmpty { 358 EmptyDashboardStateView( 359 icon: "checkmark.circle", 360 title: "No issues found", 361 subtitle: "Great! Your websites don't have any SEO issues" 362 ) 363 } else { 364 LazyVStack(spacing: 8) { 365 ForEach(issuesSummary, id: \.type) { summary in 366 IssuesSummaryRow(summary: summary) 367 } 368 } 369 } 370 } 371 .padding(20) 372 .background(Color(NSColor.controlBackgroundColor)) 373 .cornerRadius(12) 374 } 375 376 private var allIssues: [SEOIssue] { 377 seoService.analysisHistory.flatMap { $0.issues } 378 } 379 380 private var issuesSummary: [IssueSummary] { 381 let grouped = Dictionary(grouping: allIssues, by: { $0.type }) 382 return grouped.map { type, issues in 383 IssueSummary( 384 type: type, 385 count: issues.count, 386 severity: issues.map { $0.severity }.max() ?? .low 387 ) 388 }.sorted { $0.severity.sortOrder > $1.severity.sortOrder } 389 } 390 } 391 392 struct IssueSummary { 393 let type: SEOIssueType 394 let count: Int 395 let severity: SEOIssueSeverity 396 } 397 398 extension SEOIssueSeverity { 399 var sortOrder: Int { 400 switch self { 401 case .critical: return 4 402 case .high: return 3 403 case .medium: return 2 404 case .low: return 1 405 } 406 } 407 408 var color: Color { 409 switch self { 410 case .critical: return .red 411 case .high: return .orange 412 case .medium: return .yellow 413 case .low: return .blue 414 } 415 } 416 } 417 418 struct IssuesSummaryRow: View { 419 let summary: IssueSummary 420 421 var body: some View { 422 HStack(spacing: 12) { 423 Image(systemName: "exclamationmark.triangle.fill") 424 .foregroundColor(summary.severity.color) 425 426 VStack(alignment: .leading, spacing: 2) { 427 Text(summary.type.rawValue.replacingOccurrences(of: "_", with: " ").capitalized) 428 .font(.subheadline) 429 .fontWeight(.medium) 430 431 Text("\(summary.count) occurrence\(summary.count == 1 ? "" : "s")") 432 .font(.caption) 433 .foregroundColor(.secondary) 434 } 435 436 Spacer() 437 438 Text(summary.severity.rawValue.capitalized) 439 .font(.caption) 440 .fontWeight(.medium) 441 .padding(.horizontal, 8) 442 .padding(.vertical, 4) 443 .background(summary.severity.color.opacity(0.2)) 444 .foregroundColor(summary.severity.color) 445 .cornerRadius(6) 446 } 447 .padding(12) 448 .background(Color(NSColor.textBackgroundColor)) 449 .cornerRadius(8) 450 } 451 } 452 453 struct EmptyDashboardStateView: View { 454 let icon: String 455 let title: String 456 let subtitle: String 457 458 var body: some View { 459 VStack(spacing: 16) { 460 Image(systemName: icon) 461 .font(.system(size: 48)) 462 .foregroundColor(.secondary) 463 464 VStack(spacing: 8) { 465 Text(title) 466 .font(.headline) 467 .foregroundColor(.secondary) 468 469 Text(subtitle) 470 .font(.subheadline) 471 .foregroundColor(.secondary) 472 .multilineTextAlignment(.center) 473 } 474 } 475 .frame(maxWidth: .infinity) 476 .frame(height: 120) 477 } 478 } 479 480 #Preview { 481 DashboardView() 482 .environmentObject(SEOAnalysisService()) 483 .frame(width: 1000, height: 800) 484 }