/ RacerTracer / Views / SearchConsoleView.swift
SearchConsoleView.swift
  1  //
  2  //  SearchConsoleView.swift
  3  //  RacerTracer
  4  //
  5  //  Created by Alexander Kunau on 29.09.25.
  6  //
  7  
  8  import SwiftUI
  9  import Charts
 10  
 11  struct SearchConsoleView: View {
 12      @StateObject private var searchConsoleService = SearchConsoleService()
 13      @State private var websiteProperty = ""
 14      @State private var searchConsoleData: SearchConsoleData?
 15      @State private var isLoading = false
 16      @State private var selectedTimeRange: TimeRange = .last30Days
 17      @State private var selectedMetric: SearchMetric = .clicks
 18      @State private var errorMessage: String?
 19      
 20      var body: some View {
 21          VStack(spacing: 24) {
 22              // Header with Input
 23              SearchConsoleInputSection(
 24                  websiteProperty: $websiteProperty,
 25                  selectedTimeRange: $selectedTimeRange,
 26                  onAnalyze: fetchSearchConsoleData,
 27                  isLoading: isLoading
 28              )
 29              
 30              if isLoading {
 31                  SearchConsoleProgressView()
 32              } else if let data = searchConsoleData {
 33                  SearchConsoleResultsView(
 34                      data: data,
 35                      selectedMetric: $selectedMetric
 36                  )
 37              } else if let error = errorMessage {
 38                  SearchConsoleErrorView(error: error)
 39              } else {
 40                  SearchConsoleStartView()
 41              }
 42          }
 43          .padding(24)
 44          .navigationTitle("Search Console")
 45      }
 46      
 47      private func fetchSearchConsoleData() {
 48          guard !websiteProperty.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
 49          guard searchConsoleService.isCredentialsConfigured() else {
 50              errorMessage = "Please configure your Search Console API credentials in Settings"
 51              return
 52          }
 53          
 54          isLoading = true
 55          errorMessage = nil
 56          
 57          Task {
 58              do {
 59                  let data = try await searchConsoleService.fetchSearchData(
 60                      property: websiteProperty,
 61                      timeRange: selectedTimeRange
 62                  )
 63                  
 64                  await MainActor.run {
 65                      searchConsoleData = data
 66                      isLoading = false
 67                  }
 68              } catch {
 69                  await MainActor.run {
 70                      errorMessage = error.localizedDescription
 71                      isLoading = false
 72                  }
 73              }
 74          }
 75      }
 76  }
 77  
 78  // MARK: - Input Section
 79  
 80  struct SearchConsoleInputSection: View {
 81      @Binding var websiteProperty: String
 82      @Binding var selectedTimeRange: TimeRange
 83      let onAnalyze: () -> Void
 84      let isLoading: Bool
 85      
 86      var body: some View {
 87          VStack(alignment: .leading, spacing: 16) {
 88              Text("Google Search Console Analysis")
 89                  .font(.title2)
 90                  .fontWeight(.semibold)
 91              
 92              Text("Analyze search performance data from Google Search Console API including clicks, impressions, CTR, and keyword rankings")
 93                  .font(.subheadline)
 94                  .foregroundColor(.secondary)
 95              
 96              VStack(spacing: 12) {
 97                  HStack {
 98                      TextField("Website property (e.g., https://example.com/)", text: $websiteProperty)
 99                          .textFieldStyle(RoundedBorderTextFieldStyle())
100                          .disabled(isLoading)
101                      
102                      Picker("Time Range", selection: $selectedTimeRange) {
103                          ForEach(TimeRange.allCases, id: \.self) { range in
104                              Text(range.rawValue).tag(range)
105                          }
106                      }
107                      .pickerStyle(MenuPickerStyle())
108                      .frame(width: 150)
109                      .disabled(isLoading)
110                  }
111                  
112                  Button(action: onAnalyze) {
113                      HStack {
114                          if isLoading {
115                              ProgressView()
116                                  .scaleEffect(0.8)
117                          } else {
118                              Image(systemName: "magnifyingglass")
119                          }
120                          Text(isLoading ? "Fetching Data..." : "Fetch Search Data")
121                      }
122                  }
123                  .buttonStyle(.borderedProminent)
124                  .disabled(websiteProperty.isEmpty || isLoading)
125              }
126          }
127          .padding(20)
128          .background(Color(NSColor.controlBackgroundColor))
129          .cornerRadius(12)
130      }
131  }
132  
133  // MARK: - Progress View
134  
135  struct SearchConsoleProgressView: View {
136      var body: some View {
137          VStack(spacing: 20) {
138              ProgressView()
139                  .scaleEffect(1.5)
140              
141              Text("Fetching Search Console Data...")
142                  .font(.headline)
143                  .foregroundColor(.secondary)
144              
145              VStack(spacing: 8) {
146                  Text("• Authenticating with Google API")
147                  Text("• Retrieving search performance data")
148                  Text("• Processing clicks and impressions")
149                  Text("• Analyzing keyword rankings")
150              }
151              .font(.subheadline)
152              .foregroundColor(.secondary)
153          }
154          .frame(maxWidth: .infinity)
155          .frame(height: 250)
156          .background(Color(NSColor.controlBackgroundColor))
157          .cornerRadius(12)
158      }
159  }
160  
161  // MARK: - Start View
162  
163  struct SearchConsoleStartView: View {
164      var body: some View {
165          VStack(spacing: 20) {
166              Image(systemName: "magnifyingglass")
167                  .font(.system(size: 48))
168                  .foregroundColor(.accentColor)
169              
170              Text("Search Console Analytics")
171                  .font(.title2)
172                  .fontWeight(.semibold)
173              
174              Text("Connect to Google Search Console to analyze your website's search performance, keyword rankings, and click-through rates.")
175                  .font(.subheadline)
176                  .foregroundColor(.secondary)
177                  .multilineTextAlignment(.center)
178          }
179          .frame(maxWidth: .infinity, maxHeight: .infinity)
180          .background(Color(NSColor.controlBackgroundColor))
181          .cornerRadius(12)
182      }
183  }
184  
185  // MARK: - Error View
186  
187  struct SearchConsoleErrorView: View {
188      let error: String
189      
190      var body: some View {
191          VStack(spacing: 20) {
192              Image(systemName: "exclamationmark.triangle")
193                  .font(.system(size: 48))
194                  .foregroundColor(.orange)
195              
196              Text("API Error")
197                  .font(.title2)
198                  .fontWeight(.semibold)
199              
200              Text(error)
201                  .font(.subheadline)
202                  .foregroundColor(.secondary)
203                  .multilineTextAlignment(.center)
204          }
205          .frame(maxWidth: .infinity, maxHeight: .infinity)
206          .background(Color(NSColor.controlBackgroundColor))
207          .cornerRadius(12)
208      }
209  }
210  
211  // MARK: - Results View
212  
213  struct SearchConsoleResultsView: View {
214      let data: SearchConsoleData
215      @Binding var selectedMetric: SearchMetric
216      
217      var body: some View {
218          ScrollView {
219              VStack(spacing: 24) {
220                  // Overview Cards
221                  SearchPerformanceOverview(data: data)
222                  
223                  // Metric Selector
224                  Picker("Metric", selection: $selectedMetric) {
225                      ForEach(SearchMetric.allCases, id: \.self) { metric in
226                          Text(metric.rawValue).tag(metric)
227                      }
228                  }
229                  .pickerStyle(SegmentedPickerStyle())
230                  
231                  // Performance Chart
232                  SearchPerformanceChart(
233                      timeSeriesData: data.timeSeriesData,
234                      selectedMetric: selectedMetric
235                  )
236                  
237                  // Top Queries
238                  TopQueriesSection(queries: data.topQueries)
239                  
240                  // Top Pages
241                  TopPagesSection(pages: data.topPages)
242                  
243                  // Device Breakdown
244                  DevicePerformanceSection(deviceData: data.deviceBreakdown)
245                  
246                  // Country Performance
247                  CountryPerformanceSection(countryData: data.countryBreakdown)
248              }
249              .padding(20)
250          }
251          .background(Color(NSColor.controlBackgroundColor))
252          .cornerRadius(12)
253      }
254  }
255  
256  // MARK: - Overview Cards
257  
258  struct SearchPerformanceOverview: View {
259      let data: SearchConsoleData
260      
261      var body: some View {
262          VStack(alignment: .leading, spacing: 16) {
263              Text("Search Performance Overview")
264                  .font(.headline)
265                  .fontWeight(.semibold)
266              
267              HStack(spacing: 16) {
268                  SearchOverviewCard(
269                      title: "Total Clicks",
270                      value: formatNumber(data.totalClicks),
271                      change: data.clicksChange,
272                      color: .blue
273                  )
274                  
275                  SearchOverviewCard(
276                      title: "Total Impressions",
277                      value: formatNumber(data.totalImpressions),
278                      change: data.impressionsChange,
279                      color: .green
280                  )
281                  
282                  SearchOverviewCard(
283                      title: "Average CTR",
284                      value: String(format: "%.2f%%", data.averageCTR * 100),
285                      change: data.ctrChange,
286                      color: .orange
287                  )
288                  
289                  SearchOverviewCard(
290                      title: "Average Position",
291                      value: String(format: "%.1f", data.averagePosition),
292                      change: data.positionChange,
293                      color: .purple
294                  )
295              }
296          }
297      }
298      
299      private func formatNumber(_ number: Int) -> String {
300          if number >= 1000000 {
301              return String(format: "%.1fM", Double(number) / 1000000)
302          } else if number >= 1000 {
303              return String(format: "%.1fK", Double(number) / 1000)
304          } else {
305              return String(number)
306          }
307      }
308  }
309  
310  struct SearchOverviewCard: View {
311      let title: String
312      let value: String
313      let change: Double
314      let color: Color
315      
316      var body: some View {
317          VStack(spacing: 8) {
318              Text(title)
319                  .font(.caption)
320                  .foregroundColor(.secondary)
321              
322              Text(value)
323                  .font(.title2)
324                  .fontWeight(.bold)
325                  .foregroundColor(color)
326              
327              HStack(spacing: 4) {
328                  Image(systemName: change >= 0 ? "arrow.up" : "arrow.down")
329                      .foregroundColor(change >= 0 ? .green : .red)
330                      .font(.caption)
331                  
332                  Text(String(format: "%.1f%%", abs(change)))
333                      .font(.caption)
334                      .foregroundColor(change >= 0 ? .green : .red)
335              }
336          }
337          .frame(maxWidth: .infinity)
338          .padding(16)
339          .background(Color(NSColor.textBackgroundColor))
340          .cornerRadius(8)
341      }
342  }
343  
344  // MARK: - Performance Chart
345  
346  struct SearchPerformanceChart: View {
347      let timeSeriesData: [SearchTimeSeriesData]
348      let selectedMetric: SearchMetric
349      
350      var body: some View {
351          VStack(alignment: .leading, spacing: 16) {
352              Text("\(selectedMetric.rawValue) Over Time")
353                  .font(.headline)
354                  .fontWeight(.semibold)
355              
356              Chart(timeSeriesData, id: \.date) { data in
357                  LineMark(
358                      x: .value("Date", data.date),
359                      y: .value(selectedMetric.rawValue, metricValue(for: data))
360                  )
361                  .foregroundStyle(selectedMetric.color.gradient)
362                  .lineStyle(StrokeStyle(lineWidth: 2))
363                  
364                  AreaMark(
365                      x: .value("Date", data.date),
366                      y: .value(selectedMetric.rawValue, metricValue(for: data))
367                  )
368                  .foregroundStyle(selectedMetric.color.opacity(0.1))
369              }
370              .frame(height: 200)
371          }
372      }
373      
374      private func metricValue(for data: SearchTimeSeriesData) -> Double {
375          switch selectedMetric {
376          case .clicks:
377              return Double(data.clicks)
378          case .impressions:
379              return Double(data.impressions)
380          case .ctr:
381              return data.ctr * 100
382          case .position:
383              return data.position
384          }
385      }
386  }
387  
388  // MARK: - Top Queries
389  
390  struct TopQueriesSection: View {
391      let queries: [SearchQuery]
392      
393      var body: some View {
394          VStack(alignment: .leading, spacing: 16) {
395              Text("Top Search Queries")
396                  .font(.headline)
397                  .fontWeight(.semibold)
398              
399              LazyVStack(spacing: 8) {
400                  ForEach(queries.prefix(10), id: \.id) { query in
401                      SearchQueryRow(query: query)
402                  }
403              }
404          }
405      }
406  }
407  
408  struct SearchQueryRow: View {
409      let query: SearchQuery
410      
411      var body: some View {
412          HStack {
413              VStack(alignment: .leading, spacing: 4) {
414                  Text(query.query)
415                      .font(.subheadline)
416                      .fontWeight(.medium)
417                  
418                  HStack(spacing: 16) {
419                      Text("\(query.clicks) clicks")
420                          .font(.caption)
421                          .foregroundColor(.blue)
422                      
423                      Text("\(query.impressions) impressions")
424                          .font(.caption)
425                          .foregroundColor(.green)
426                      
427                      Text("CTR: \(String(format: "%.2f%%", query.ctr * 100))")
428                          .font(.caption)
429                          .foregroundColor(.orange)
430                  }
431              }
432              
433              Spacer()
434              
435              VStack(alignment: .trailing, spacing: 4) {
436                  Text("Pos: \(String(format: "%.1f", query.position))")
437                      .font(.caption)
438                      .fontWeight(.medium)
439                      .foregroundColor(.purple)
440              }
441          }
442          .padding(12)
443          .background(Color(NSColor.textBackgroundColor))
444          .cornerRadius(8)
445      }
446  }
447  
448  // MARK: - Top Pages
449  
450  struct TopPagesSection: View {
451      let pages: [SearchPage]
452      
453      var body: some View {
454          VStack(alignment: .leading, spacing: 16) {
455              Text("Top Landing Pages")
456                  .font(.headline)
457                  .fontWeight(.semibold)
458              
459              LazyVStack(spacing: 8) {
460                  ForEach(pages.prefix(10), id: \.id) { page in
461                      SearchPageRow(page: page)
462                  }
463              }
464          }
465      }
466  }
467  
468  struct SearchPageRow: View {
469      let page: SearchPage
470      
471      var body: some View {
472          HStack {
473              VStack(alignment: .leading, spacing: 4) {
474                  Text(URL(string: page.url)?.path ?? page.url)
475                      .font(.subheadline)
476                      .fontWeight(.medium)
477                      .lineLimit(1)
478                  
479                  HStack(spacing: 16) {
480                      Text("\(page.clicks) clicks")
481                          .font(.caption)
482                          .foregroundColor(.blue)
483                      
484                      Text("\(page.impressions) impressions")
485                          .font(.caption)
486                          .foregroundColor(.green)
487                      
488                      Text("CTR: \(String(format: "%.2f%%", page.ctr * 100))")
489                          .font(.caption)
490                          .foregroundColor(.orange)
491                  }
492              }
493              
494              Spacer()
495              
496              VStack(alignment: .trailing, spacing: 4) {
497                  Text("Pos: \(String(format: "%.1f", page.position))")
498                      .font(.caption)
499                      .fontWeight(.medium)
500                      .foregroundColor(.purple)
501              }
502          }
503          .padding(12)
504          .background(Color(NSColor.textBackgroundColor))
505          .cornerRadius(8)
506      }
507  }
508  
509  // MARK: - Device Performance
510  
511  struct DevicePerformanceSection: View {
512      let deviceData: [DevicePerformance]
513      
514      var body: some View {
515          VStack(alignment: .leading, spacing: 16) {
516              Text("Performance by Device")
517                  .font(.headline)
518                  .fontWeight(.semibold)
519              
520              Chart(deviceData, id: \.device) { data in
521                  BarMark(
522                      x: .value("Device", data.device),
523                      y: .value("Clicks", data.clicks)
524                  )
525                  .foregroundStyle(Color.blue.gradient)
526              }
527              .frame(height: 150)
528          }
529      }
530  }
531  
532  // MARK: - Country Performance
533  
534  struct CountryPerformanceSection: View {
535      let countryData: [CountryPerformance]
536      
537      var body: some View {
538          VStack(alignment: .leading, spacing: 16) {
539              Text("Performance by Country")
540                  .font(.headline)
541                  .fontWeight(.semibold)
542              
543              LazyVStack(spacing: 8) {
544                  ForEach(countryData.prefix(5), id: \.id) { country in
545                      CountryPerformanceRow(country: country)
546                  }
547              }
548          }
549      }
550  }
551  
552  struct CountryPerformanceRow: View {
553      let country: CountryPerformance
554      
555      var body: some View {
556          HStack {
557              Text(country.country)
558                  .font(.subheadline)
559                  .fontWeight(.medium)
560              
561              Spacer()
562              
563              HStack(spacing: 16) {
564                  Text("\(country.clicks) clicks")
565                      .font(.caption)
566                      .foregroundColor(.blue)
567                  
568                  Text("\(country.impressions) impr.")
569                      .font(.caption)
570                      .foregroundColor(.green)
571                  
572                  Text("\(String(format: "%.2f%%", country.ctr * 100)) CTR")
573                      .font(.caption)
574                      .foregroundColor(.orange)
575              }
576          }
577          .padding(12)
578          .background(Color(NSColor.textBackgroundColor))
579          .cornerRadius(8)
580      }
581  }
582  
583  // MARK: - Data Models
584  
585  enum SearchMetric: String, CaseIterable {
586      case clicks = "Clicks"
587      case impressions = "Impressions"
588      case ctr = "CTR"
589      case position = "Position"
590      
591      var color: Color {
592          switch self {
593          case .clicks: return .blue
594          case .impressions: return .green
595          case .ctr: return .orange
596          case .position: return .purple
597          }
598      }
599  }
600  
601  struct SearchConsoleData {
602      let property: String
603      let timeRange: TimeRange
604      let totalClicks: Int
605      let totalImpressions: Int
606      let averageCTR: Double
607      let averagePosition: Double
608      let clicksChange: Double
609      let impressionsChange: Double
610      let ctrChange: Double
611      let positionChange: Double
612      let timeSeriesData: [SearchTimeSeriesData]
613      let topQueries: [SearchQuery]
614      let topPages: [SearchPage]
615      let deviceBreakdown: [DevicePerformance]
616      let countryBreakdown: [CountryPerformance]
617  }
618  
619  struct SearchTimeSeriesData {
620      let date: Date
621      let clicks: Int
622      let impressions: Int
623      let ctr: Double
624      let position: Double
625  }
626  
627  struct SearchQuery: Identifiable {
628      let id = UUID()
629      let query: String
630      let clicks: Int
631      let impressions: Int
632      let ctr: Double
633      let position: Double
634  }
635  
636  struct SearchPage: Identifiable {
637      let id = UUID()
638      let url: String
639      let clicks: Int
640      let impressions: Int
641      let ctr: Double
642      let position: Double
643  }
644  
645  struct DevicePerformance {
646      let device: String
647      let clicks: Int
648      let impressions: Int
649      let ctr: Double
650      let position: Double
651  }
652  
653  struct CountryPerformance: Identifiable {
654      let id = UUID()
655      let country: String
656      let clicks: Int
657      let impressions: Int
658      let ctr: Double
659      let position: Double
660  }
661  
662  // MARK: - Search Console Service
663  
664  @MainActor
665  class SearchConsoleService: ObservableObject {
666      private var credentials: String {
667          return UserDefaults.standard.string(forKey: "SearchConsoleCredentials") ?? ""
668      }
669      
670      func isCredentialsConfigured() -> Bool {
671          return !credentials.isEmpty
672      }
673      
674      func fetchSearchData(property: String, timeRange: TimeRange) async throws -> SearchConsoleData {
675          // In a real implementation, this would make actual API calls to Google Search Console
676          // For now, we'll return mock data
677          
678          try await Task.sleep(nanoseconds: 2_000_000_000) // Simulate API delay
679          
680          return generateMockSearchConsoleData(property: property, timeRange: timeRange)
681      }
682      
683      private func generateMockSearchConsoleData(property: String, timeRange: TimeRange) -> SearchConsoleData {
684          let calendar = Calendar.current
685          let endDate = Date()
686          let startDate = calendar.date(byAdding: .day, value: -28, to: endDate) ?? endDate
687          
688          // Generate time series data
689          var timeSeriesData: [SearchTimeSeriesData] = []
690          var currentDate = startDate
691          
692          while currentDate <= endDate {
693              timeSeriesData.append(SearchTimeSeriesData(
694                  date: currentDate,
695                  clicks: Int.random(in: 50...500),
696                  impressions: Int.random(in: 1000...5000),
697                  ctr: Double.random(in: 0.02...0.15),
698                  position: Double.random(in: 5...25)
699              ))
700              currentDate = calendar.date(byAdding: .day, value: 1, to: currentDate) ?? currentDate
701          }
702          
703          // Generate top queries
704          let sampleQueries = [
705              "seo optimization", "website analysis", "keyword research", "google rankings",
706              "technical seo", "page speed", "search console", "site audit", "meta tags",
707              "backlink analysis", "content optimization", "local seo", "mobile seo"
708          ]
709          
710          let topQueries = sampleQueries.shuffled().prefix(10).map { query in
711              SearchQuery(
712                  query: query,
713                  clicks: Int.random(in: 10...200),
714                  impressions: Int.random(in: 500...2000),
715                  ctr: Double.random(in: 0.02...0.12),
716                  position: Double.random(in: 3...20)
717              )
718          }
719          
720          // Generate top pages
721          let samplePages = [
722              "/", "/blog/seo-guide", "/services", "/about", "/contact",
723              "/blog/keyword-research", "/tools", "/pricing", "/blog/technical-seo"
724          ]
725          
726          let topPages = samplePages.map { path in
727              let fullURL = property.hasSuffix("/") ? property + path.dropFirst() : property + path
728              return SearchPage(
729                  url: fullURL,
730                  clicks: Int.random(in: 20...300),
731                  impressions: Int.random(in: 800...3000),
732                  ctr: Double.random(in: 0.025...0.1),
733                  position: Double.random(in: 4...18)
734              )
735          }
736          
737          // Generate device breakdown
738          let deviceBreakdown = [
739              DevicePerformance(device: "Mobile", clicks: 1250, impressions: 12500, ctr: 0.1, position: 8.5),
740              DevicePerformance(device: "Desktop", clicks: 800, impressions: 6400, ctr: 0.125, position: 7.2),
741              DevicePerformance(device: "Tablet", clicks: 150, impressions: 1200, ctr: 0.125, position: 9.1)
742          ]
743          
744          // Generate country breakdown
745          let countryBreakdown = [
746              CountryPerformance(country: "United States", clicks: 1200, impressions: 11000, ctr: 0.109, position: 7.8),
747              CountryPerformance(country: "Germany", clicks: 400, impressions: 4200, ctr: 0.095, position: 8.2),
748              CountryPerformance(country: "United Kingdom", clicks: 350, impressions: 3800, ctr: 0.092, position: 8.5),
749              CountryPerformance(country: "Canada", clicks: 250, impressions: 2500, ctr: 0.1, position: 8.0)
750          ]
751          
752          let totalClicks = timeSeriesData.reduce(0) { $0 + $1.clicks }
753          let totalImpressions = timeSeriesData.reduce(0) { $0 + $1.impressions }
754          let averageCTR = timeSeriesData.map(\.ctr).reduce(0, +) / Double(timeSeriesData.count)
755          let averagePosition = timeSeriesData.map(\.position).reduce(0, +) / Double(timeSeriesData.count)
756          
757          return SearchConsoleData(
758              property: property,
759              timeRange: timeRange,
760              totalClicks: totalClicks,
761              totalImpressions: totalImpressions,
762              averageCTR: averageCTR,
763              averagePosition: averagePosition,
764              clicksChange: Double.random(in: -15...25),
765              impressionsChange: Double.random(in: -10...30),
766              ctrChange: Double.random(in: -5...15),
767              positionChange: Double.random(in: -2...3),
768              timeSeriesData: timeSeriesData,
769              topQueries: Array(topQueries),
770              topPages: topPages,
771              deviceBreakdown: deviceBreakdown,
772              countryBreakdown: countryBreakdown
773          )
774      }
775  }
776  
777  #Preview {
778      SearchConsoleView()
779  }