PageSpeedInsightsView.swift
1 // 2 // PageSpeedInsightsView.swift 3 // RacerTracer 4 // 5 // Created by Alexander Kunau on 29.09.25. 6 // 7 8 import SwiftUI 9 import Charts 10 11 struct PageSpeedInsightsView: View { 12 @StateObject private var pageSpeedService = PageSpeedInsightsService() 13 @State private var websiteURL = "" 14 @State private var pageSpeedResults: PageSpeedResults? 15 @State private var isAnalyzing = false 16 @State private var selectedDevice: DeviceType = .mobile 17 @State private var errorMessage: String? 18 19 var body: some View { 20 VStack(spacing: 24) { 21 // Header with Input 22 PageSpeedInputSection( 23 websiteURL: $websiteURL, 24 selectedDevice: $selectedDevice, 25 onAnalyze: performPageSpeedAnalysis, 26 isAnalyzing: isAnalyzing 27 ) 28 29 if isAnalyzing { 30 PageSpeedProgressView() 31 } else if let results = pageSpeedResults { 32 PageSpeedResultsView(results: results) 33 } else if let error = errorMessage { 34 PageSpeedErrorView(error: error) 35 } else { 36 PageSpeedStartView() 37 } 38 } 39 .padding(24) 40 .navigationTitle("PageSpeed Insights") 41 } 42 43 private func performPageSpeedAnalysis() { 44 guard !websiteURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } 45 guard pageSpeedService.isAPIKeyConfigured() else { 46 errorMessage = "Please configure your PageSpeed Insights API key in Settings" 47 return 48 } 49 50 isAnalyzing = true 51 errorMessage = nil 52 53 Task { 54 do { 55 let results = try await pageSpeedService.analyzePageSpeed( 56 url: websiteURL, 57 device: selectedDevice 58 ) 59 60 await MainActor.run { 61 pageSpeedResults = results 62 isAnalyzing = false 63 } 64 } catch { 65 await MainActor.run { 66 errorMessage = error.localizedDescription 67 isAnalyzing = false 68 } 69 } 70 } 71 } 72 } 73 74 // MARK: - Input Section 75 76 struct PageSpeedInputSection: View { 77 @Binding var websiteURL: String 78 @Binding var selectedDevice: DeviceType 79 let onAnalyze: () -> Void 80 let isAnalyzing: Bool 81 82 var body: some View { 83 VStack(alignment: .leading, spacing: 16) { 84 Text("Google PageSpeed Insights Analysis") 85 .font(.title2) 86 .fontWeight(.semibold) 87 88 Text("Analyze website performance using Google's PageSpeed Insights API with Core Web Vitals") 89 .font(.subheadline) 90 .foregroundColor(.secondary) 91 92 VStack(spacing: 12) { 93 HStack { 94 TextField("Website URL for PageSpeed analysis", text: $websiteURL) 95 .textFieldStyle(RoundedBorderTextFieldStyle()) 96 .disabled(isAnalyzing) 97 98 Picker("Device", selection: $selectedDevice) { 99 ForEach(DeviceType.allCases, id: \.self) { device in 100 Text(device.rawValue).tag(device) 101 } 102 } 103 .pickerStyle(SegmentedPickerStyle()) 104 .frame(width: 200) 105 .disabled(isAnalyzing) 106 } 107 108 Button(action: onAnalyze) { 109 HStack { 110 if isAnalyzing { 111 ProgressView() 112 .scaleEffect(0.8) 113 } else { 114 Image(systemName: "speedometer") 115 } 116 Text(isAnalyzing ? "Analyzing Performance..." : "Analyze Performance") 117 } 118 } 119 .buttonStyle(.borderedProminent) 120 .disabled(websiteURL.isEmpty || isAnalyzing) 121 } 122 } 123 .padding(20) 124 .background(Color(NSColor.controlBackgroundColor)) 125 .cornerRadius(12) 126 } 127 } 128 129 // MARK: - Progress View 130 131 struct PageSpeedProgressView: View { 132 var body: some View { 133 VStack(spacing: 20) { 134 ProgressView() 135 .scaleEffect(1.5) 136 137 Text("Running PageSpeed Analysis...") 138 .font(.headline) 139 .foregroundColor(.secondary) 140 141 VStack(spacing: 8) { 142 Text("• Measuring Core Web Vitals") 143 Text("• Analyzing page performance") 144 Text("• Generating optimization suggestions") 145 Text("• Calculating performance score") 146 } 147 .font(.subheadline) 148 .foregroundColor(.secondary) 149 } 150 .frame(maxWidth: .infinity) 151 .frame(height: 250) 152 .background(Color(NSColor.controlBackgroundColor)) 153 .cornerRadius(12) 154 } 155 } 156 157 // MARK: - Start View 158 159 struct PageSpeedStartView: View { 160 var body: some View { 161 VStack(spacing: 20) { 162 Image(systemName: "speedometer") 163 .font(.system(size: 48)) 164 .foregroundColor(.accentColor) 165 166 Text("PageSpeed Insights") 167 .font(.title2) 168 .fontWeight(.semibold) 169 170 Text("Analyze website performance with Google's PageSpeed Insights API. Get detailed Core Web Vitals metrics and optimization suggestions.") 171 .font(.subheadline) 172 .foregroundColor(.secondary) 173 .multilineTextAlignment(.center) 174 } 175 .frame(maxWidth: .infinity, maxHeight: .infinity) 176 .background(Color(NSColor.controlBackgroundColor)) 177 .cornerRadius(12) 178 } 179 } 180 181 // MARK: - Error View 182 183 struct PageSpeedErrorView: View { 184 let error: String 185 186 var body: some View { 187 VStack(spacing: 20) { 188 Image(systemName: "exclamationmark.triangle") 189 .font(.system(size: 48)) 190 .foregroundColor(.orange) 191 192 Text("Analysis Error") 193 .font(.title2) 194 .fontWeight(.semibold) 195 196 Text(error) 197 .font(.subheadline) 198 .foregroundColor(.secondary) 199 .multilineTextAlignment(.center) 200 } 201 .frame(maxWidth: .infinity, maxHeight: .infinity) 202 .background(Color(NSColor.controlBackgroundColor)) 203 .cornerRadius(12) 204 } 205 } 206 207 // MARK: - Results View 208 209 struct PageSpeedResultsView: View { 210 let results: PageSpeedResults 211 212 var body: some View { 213 ScrollView { 214 VStack(spacing: 24) { 215 // Performance Score Overview 216 PageSpeedScoreOverview(results: results) 217 218 // Core Web Vitals 219 CoreWebVitalsSection(metrics: results.coreWebVitals) 220 221 // Performance Metrics 222 PerformanceMetricsChart(metrics: results.performanceMetrics) 223 224 // Opportunities 225 OptimizationOpportunitiesSection(opportunities: results.opportunities) 226 227 // Diagnostics 228 DiagnosticsSection(diagnostics: results.diagnostics) 229 } 230 .padding(20) 231 } 232 .background(Color(NSColor.controlBackgroundColor)) 233 .cornerRadius(12) 234 } 235 } 236 237 // MARK: - Score Overview 238 239 struct PageSpeedScoreOverview: View { 240 let results: PageSpeedResults 241 242 var body: some View { 243 VStack(spacing: 16) { 244 Text("Performance Score") 245 .font(.headline) 246 .fontWeight(.semibold) 247 248 ZStack { 249 Circle() 250 .strokeBorder(Color.secondary.opacity(0.3), lineWidth: 12) 251 .frame(width: 120, height: 120) 252 253 Circle() 254 .trim(from: 0, to: results.performanceScore / 100) 255 .stroke(scoreColor, style: StrokeStyle(lineWidth: 12, lineCap: .round)) 256 .frame(width: 120, height: 120) 257 .rotationEffect(.degrees(-90)) 258 259 VStack(spacing: 4) { 260 Text("\(Int(results.performanceScore))") 261 .font(.largeTitle) 262 .fontWeight(.bold) 263 .foregroundColor(scoreColor) 264 265 Text("Score") 266 .font(.caption) 267 .foregroundColor(.secondary) 268 } 269 } 270 271 HStack(spacing: 20) { 272 Text("Device: \(results.device.rawValue)") 273 .font(.subheadline) 274 .foregroundColor(.secondary) 275 276 Text("Analyzed: \(results.analysisDate, style: .time)") 277 .font(.subheadline) 278 .foregroundColor(.secondary) 279 } 280 } 281 } 282 283 private var scoreColor: Color { 284 switch results.performanceScore { 285 case 90...: return .green 286 case 50..<90: return .orange 287 default: return .red 288 } 289 } 290 } 291 292 // MARK: - Core Web Vitals 293 294 struct CoreWebVitalsSection: View { 295 let metrics: CoreWebVitalsMetrics 296 297 var body: some View { 298 VStack(alignment: .leading, spacing: 16) { 299 Text("Core Web Vitals") 300 .font(.headline) 301 .fontWeight(.semibold) 302 303 HStack(spacing: 16) { 304 CoreWebVitalCard( 305 title: "LCP", 306 subtitle: "Largest Contentful Paint", 307 value: String(format: "%.1fs", metrics.largestContentfulPaint), 308 status: vitalStatus(metrics.largestContentfulPaint, good: 2.5, poor: 4.0) 309 ) 310 311 CoreWebVitalCard( 312 title: "FID", 313 subtitle: "First Input Delay", 314 value: String(format: "%.0fms", metrics.firstInputDelay), 315 status: vitalStatus(metrics.firstInputDelay, good: 100, poor: 300) 316 ) 317 318 CoreWebVitalCard( 319 title: "CLS", 320 subtitle: "Cumulative Layout Shift", 321 value: String(format: "%.3f", metrics.cumulativeLayoutShift), 322 status: vitalStatus(metrics.cumulativeLayoutShift, good: 0.1, poor: 0.25) 323 ) 324 } 325 } 326 } 327 328 private func vitalStatus(_ value: Double, good: Double, poor: Double) -> VitalStatus { 329 if value <= good { return .good } 330 if value <= poor { return .needsImprovement } 331 return .poor 332 } 333 } 334 335 struct CoreWebVitalCard: View { 336 let title: String 337 let subtitle: String 338 let value: String 339 let status: VitalStatus 340 341 var body: some View { 342 VStack(spacing: 8) { 343 Text(title) 344 .font(.caption) 345 .foregroundColor(.secondary) 346 347 Text(value) 348 .font(.title3) 349 .fontWeight(.bold) 350 .foregroundColor(status.color) 351 352 Text(subtitle) 353 .font(.caption2) 354 .foregroundColor(.secondary) 355 .multilineTextAlignment(.center) 356 357 Text(status.rawValue) 358 .font(.caption2) 359 .padding(.horizontal, 6) 360 .padding(.vertical, 2) 361 .background(status.color.opacity(0.2)) 362 .foregroundColor(status.color) 363 .cornerRadius(4) 364 } 365 .frame(maxWidth: .infinity) 366 .padding(12) 367 .background(Color(NSColor.textBackgroundColor)) 368 .cornerRadius(8) 369 } 370 } 371 372 // MARK: - Performance Metrics Chart 373 374 struct PerformanceMetricsChart: View { 375 let metrics: PageSpeedPerformanceMetrics 376 377 var body: some View { 378 VStack(alignment: .leading, spacing: 16) { 379 Text("Performance Metrics") 380 .font(.headline) 381 .fontWeight(.semibold) 382 383 Chart(performanceData, id: \.name) { data in 384 BarMark( 385 x: .value("Metric", data.name), 386 y: .value("Time", data.value) 387 ) 388 .foregroundStyle(data.color.gradient) 389 } 390 .frame(height: 200) 391 .chartYAxis { 392 AxisMarks { value in 393 AxisValueLabel { 394 if let doubleValue = value.as(Double.self) { 395 Text("\(String(format: "%.1f", doubleValue))s") 396 } 397 } 398 } 399 } 400 } 401 } 402 403 private var performanceData: [PerformanceChartData] { 404 [ 405 PerformanceChartData(name: "FCP", value: metrics.firstContentfulPaint, color: .blue), 406 PerformanceChartData(name: "LCP", value: metrics.largestContentfulPaint, color: .green), 407 PerformanceChartData(name: "TTI", value: metrics.timeToInteractive, color: .orange), 408 PerformanceChartData(name: "TBT", value: metrics.totalBlockingTime / 1000, color: .red), 409 PerformanceChartData(name: "SI", value: metrics.speedIndex, color: .purple) 410 ] 411 } 412 } 413 414 struct PerformanceChartData { 415 let name: String 416 let value: Double 417 let color: Color 418 } 419 420 // MARK: - Optimization Opportunities 421 422 struct OptimizationOpportunitiesSection: View { 423 let opportunities: [OptimizationOpportunity] 424 425 var body: some View { 426 VStack(alignment: .leading, spacing: 16) { 427 Text("Optimization Opportunities") 428 .font(.headline) 429 .fontWeight(.semibold) 430 431 LazyVStack(spacing: 12) { 432 ForEach(opportunities, id: \.id) { opportunity in 433 OptimizationOpportunityCard(opportunity: opportunity) 434 } 435 } 436 } 437 } 438 } 439 440 struct OptimizationOpportunityCard: View { 441 let opportunity: OptimizationOpportunity 442 443 var body: some View { 444 VStack(alignment: .leading, spacing: 12) { 445 HStack { 446 Image(systemName: "lightbulb.fill") 447 .foregroundColor(.yellow) 448 449 VStack(alignment: .leading, spacing: 4) { 450 Text(opportunity.title) 451 .font(.subheadline) 452 .fontWeight(.semibold) 453 454 Text("Potential savings: \(String(format: "%.1f", opportunity.potentialSavings))s") 455 .font(.caption) 456 .foregroundColor(.green) 457 } 458 459 Spacer() 460 461 Text(opportunity.impact.rawValue) 462 .font(.caption) 463 .padding(.horizontal, 8) 464 .padding(.vertical, 2) 465 .background(opportunity.impact.color.opacity(0.2)) 466 .foregroundColor(opportunity.impact.color) 467 .cornerRadius(4) 468 } 469 470 Text(opportunity.description) 471 .font(.subheadline) 472 .foregroundColor(.secondary) 473 } 474 .padding(16) 475 .background(Color(NSColor.textBackgroundColor)) 476 .cornerRadius(8) 477 } 478 } 479 480 // MARK: - Diagnostics 481 482 struct DiagnosticsSection: View { 483 let diagnostics: [PageSpeedDiagnostic] 484 485 var body: some View { 486 VStack(alignment: .leading, spacing: 16) { 487 Text("Diagnostics") 488 .font(.headline) 489 .fontWeight(.semibold) 490 491 LazyVStack(spacing: 8) { 492 ForEach(diagnostics, id: \.id) { diagnostic in 493 DiagnosticCard(diagnostic: diagnostic) 494 } 495 } 496 } 497 } 498 } 499 500 struct DiagnosticCard: View { 501 let diagnostic: PageSpeedDiagnostic 502 503 var body: some View { 504 HStack { 505 Image(systemName: diagnostic.status.icon) 506 .foregroundColor(diagnostic.status.color) 507 508 VStack(alignment: .leading, spacing: 4) { 509 Text(diagnostic.title) 510 .font(.subheadline) 511 .fontWeight(.medium) 512 513 Text(diagnostic.description) 514 .font(.caption) 515 .foregroundColor(.secondary) 516 } 517 518 Spacer() 519 } 520 .padding(12) 521 .background(Color(NSColor.textBackgroundColor)) 522 .cornerRadius(8) 523 } 524 } 525 526 // MARK: - Data Models 527 528 enum DeviceType: String, CaseIterable { 529 case mobile = "Mobile" 530 case desktop = "Desktop" 531 } 532 533 struct PageSpeedResults { 534 let url: String 535 let device: DeviceType 536 let performanceScore: Double 537 let analysisDate: Date 538 let coreWebVitals: CoreWebVitalsMetrics 539 let performanceMetrics: PageSpeedPerformanceMetrics 540 let opportunities: [OptimizationOpportunity] 541 let diagnostics: [PageSpeedDiagnostic] 542 } 543 544 struct CoreWebVitalsMetrics { 545 let largestContentfulPaint: Double 546 let firstInputDelay: Double 547 let cumulativeLayoutShift: Double 548 } 549 550 struct PageSpeedPerformanceMetrics { 551 let firstContentfulPaint: Double 552 let largestContentfulPaint: Double 553 let timeToInteractive: Double 554 let totalBlockingTime: Double 555 let speedIndex: Double 556 } 557 558 struct OptimizationOpportunity: Identifiable { 559 let id = UUID() 560 let title: String 561 let description: String 562 let potentialSavings: Double 563 let impact: OptimizationImpact 564 } 565 566 enum OptimizationImpact { 567 case low 568 case medium 569 case high 570 571 var rawValue: String { 572 switch self { 573 case .low: return "Low" 574 case .medium: return "Medium" 575 case .high: return "High" 576 } 577 } 578 579 var color: Color { 580 switch self { 581 case .low: return .green 582 case .medium: return .orange 583 case .high: return .red 584 } 585 } 586 } 587 588 struct PageSpeedDiagnostic: Identifiable { 589 let id = UUID() 590 let title: String 591 let description: String 592 let status: DiagnosticStatus 593 } 594 595 enum DiagnosticStatus { 596 case passed 597 case warning 598 case failed 599 600 var icon: String { 601 switch self { 602 case .passed: return "checkmark.circle.fill" 603 case .warning: return "exclamationmark.triangle.fill" 604 case .failed: return "xmark.circle.fill" 605 } 606 } 607 608 var color: Color { 609 switch self { 610 case .passed: return .green 611 case .warning: return .orange 612 case .failed: return .red 613 } 614 } 615 } 616 617 enum VitalStatus: String { 618 case good = "Good" 619 case needsImprovement = "Needs Improvement" 620 case poor = "Poor" 621 622 var color: Color { 623 switch self { 624 case .good: return .green 625 case .needsImprovement: return .orange 626 case .poor: return .red 627 } 628 } 629 } 630 631 #Preview { 632 PageSpeedInsightsView() 633 .environmentObject(PageSpeedInsightsService()) 634 .frame(width: 800, height: 600) 635 }