Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Modules/Sources/JetpackStats/Analytics/StatsEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ public enum StatsEvent {
/// Subscribers tab shown
case subscribersTabShown

/// Ads tab shown
case adsTabShown

/// Post details screen shown
case postDetailsScreenShown

Expand Down
4 changes: 2 additions & 2 deletions Modules/Sources/JetpackStats/Cards/ChartCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ struct ChartCard: View {

private func headerView(for metric: SiteMetric) -> some View {
HStack(alignment: .center) {
StatsCardTitleView(title: metric.localizedTitle, showChevron: false)
StatsCardTitleView(title: metric.localizedTitle)
Spacer(minLength: 0)
}
.accessibilityElement(children: .combine)
Expand Down Expand Up @@ -101,7 +101,7 @@ struct ChartCard: View {
)
} else if viewModel.isFirstLoad {
ChartValuesSummaryView(
trend: .init(currentValue: 100, previousValue: 10, metric: .views),
trend: .init(currentValue: 100, previousValue: 10, metric: SiteMetric.views),
style: .compact
)
.redacted(reason: .placeholder)
Expand Down
4 changes: 2 additions & 2 deletions Modules/Sources/JetpackStats/Cards/ChartCardViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ final class ChartCardViewModel: ObservableObject, TrafficCardViewModel {
return output
}

var tabViewData: [MetricsOverviewTabView.MetricData] {
var tabViewData: [MetricsOverviewTabView<SiteMetric>.MetricData] {
metrics.map { metric in
if let chartData = chartData[metric] {
return .init(
Expand All @@ -215,7 +215,7 @@ final class ChartCardViewModel: ObservableObject, TrafficCardViewModel {
}
}

var placeholderTabViewData: [MetricsOverviewTabView.MetricData] {
var placeholderTabViewData: [MetricsOverviewTabView<SiteMetric>.MetricData] {
metrics.map { metric in
.init(metric: metric, value: 12345, previousValue: 11234)
}
Expand Down
4 changes: 2 additions & 2 deletions Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ struct StandaloneChartCard: View {
)
} else {
ChartValuesSummaryView(
trend: .init(currentValue: 100, previousValue: 10, metric: .views),
trend: .init(currentValue: 100, previousValue: 10, metric: SiteMetric.views),
style: .compact
)
.redacted(reason: .placeholder)
Expand Down Expand Up @@ -229,7 +229,7 @@ struct StandaloneChartCard: View {
}

@ViewBuilder
private func navigationButton(direction: Calendar.NavigationDirection) -> some View {
private func navigationButton(direction: NavigationDirection) -> some View {
Button {
dateRange = dateRange.navigate(direction)
} label: {
Expand Down
170 changes: 170 additions & 0 deletions Modules/Sources/JetpackStats/Cards/WordAdsChartCard.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import SwiftUI

/// A chart card for displaying WordAds metrics with granularity selection and period navigation.
struct WordAdsChartCard: View {
@ObservedObject var viewModel: WordAdsChartCardViewModel

@ScaledMetric(relativeTo: .body) private var chartHeight: CGFloat = 180

var body: some View {
VStack(spacing: 0) {
header
.padding(.horizontal, Constants.step3)
.padding(.top, Constants.step2)
.padding(.bottom, Constants.step1)

chartArea
.frame(height: chartHeight)
.padding(.horizontal, Constants.step2)
.padding(.vertical, Constants.step2)
.animation(.spring, value: viewModel.selectedMetric)
.animation(.easeInOut, value: viewModel.isLoading)

Divider()

footer
}
.onAppear {
viewModel.onAppear()
}
.cardStyle()
}

// MARK: - Header

private var header: some View {
HStack(spacing: 0) {
StatsCardTitleView(title: viewModel.formattedCurrentDate)

Spacer(minLength: 8)

granularityMenu
}
}

private var granularityMenu: some View {
Menu {
ForEach(DateRangeGranularity.allCases.filter { $0 != .hour }) { granularity in
Button {
viewModel.onGranularityChanged(granularity)
} label: {
Text(granularity.localizedTitle)
}
}
} label: {
HStack(spacing: 4) {
Text(viewModel.selectedGranularity.localizedTitle)
.font(.subheadline.weight(.medium))
Image(systemName: "chevron.up.chevron.down")
.font(.caption2.weight(.semibold))
}
.foregroundStyle(Color.primary)
}
.accessibilityLabel(Strings.Chart.granularity)
}

// MARK: - Chart Area

@ViewBuilder
private var chartArea: some View {
if viewModel.isFirstLoad {
loadingView
} else if let data = viewModel.currentChartData {
if data.isEmpty {
loadingErrorView(with: Strings.Chart.empty)
} else {
chartView(data: data)
.opacity(viewModel.isLoading ? 0.3 : 1.0)
.transition(.opacity.combined(with: .scale(scale: 0.97)))
}
} else {
loadingErrorView(with: viewModel.loadingError?.localizedDescription ?? Strings.Errors.generic)
}
}

private var loadingView: some View {
mockChartView
.opacity(0.2)
.pulsating()
}

private func loadingErrorView(with message: String) -> some View {
mockChartView
.grayscale(1)
.opacity(0.1)
.overlay {
SimpleErrorView(message: message)
}
}

private var mockChartView: some View {
SimpleBarChartView(
data: SimpleChartData.mock(
metric: viewModel.selectedMetric,
granularity: viewModel.selectedGranularity,
dataPointCount: viewModel.selectedGranularity.preferredQuantity
),
selectedDate: nil,
onBarTapped: { _ in }
)
.redacted(reason: .placeholder)
}

private func chartView(data: SimpleChartData) -> some View {
SimpleBarChartView(
data: data,
selectedDate: viewModel.selectedBarDate,
onBarTapped: { date in
viewModel.onBarTapped(date)
}
)
}

// MARK: - Footer

private var footer: some View {
MetricsOverviewTabView(
data: viewModel.isFirstLoad ? viewModel.placeholderTabViewData : viewModel.tabViewData,
selectedMetric: $viewModel.selectedMetric,
onMetricSelected: { metric in
viewModel.onMetricSelected(metric)
},
showTrend: false
)
.redacted(reason: viewModel.isLoading ? .placeholder : [])
.pulsating(viewModel.isLoading)
.background(
CardGradientBackground(metric: viewModel.selectedMetric)
)
.animation(.easeInOut, value: viewModel.selectedMetric)
}
}

// MARK: - Supporting Views

private struct CardGradientBackground: View {
let metric: WordAdsMetric

@Environment(\.colorScheme) var colorScheme

var body: some View {
LinearGradient(
colors: [
metric.primaryColor.opacity(colorScheme == .light ? 0.03 : 0.04),
Constants.Colors.secondaryBackground
],
startPoint: .top,
endPoint: .center
)
}
}

// MARK: - Preview

#Preview {
WordAdsChartCard(
viewModel: WordAdsChartCardViewModel(service: MockStatsService())
)
.padding()
.background(Constants.Colors.background)
}
159 changes: 159 additions & 0 deletions Modules/Sources/JetpackStats/Cards/WordAdsChartCardViewModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import SwiftUI

/// ViewModel managing state and interactions for the WordAds chart card.
@MainActor
final class WordAdsChartCardViewModel: ObservableObject {
// MARK: - Published Properties

@Published private(set) var chartData: [WordAdsMetric: SimpleChartData] = [:]
@Published private(set) var isFirstLoad = true
@Published private(set) var isLoading = false
@Published private(set) var loadingError: Error?

@Published var selectedMetric: WordAdsMetric = .impressions
@Published var selectedGranularity: DateRangeGranularity = .day
@Published var currentDate = Date()
@Published var selectedBarDate: Date?

// MARK: - Dependencies

private let service: any StatsServiceProtocol
private var loadTask: Task<Void, Never>?

// MARK: - Computed Properties

var tabViewData: [MetricsOverviewTabView<WordAdsMetric>.MetricData] {
WordAdsMetric.allMetrics.map { metric in
let data = chartData[metric]
let value: Int? = {
guard let selectedBarDate else {
return data?.currentTotal
}
// Find the data point matching the selected date
return data?.currentData.first { dataPoint in
Calendar.current.isDate(
dataPoint.date,
equalTo: selectedBarDate,
toGranularity: selectedGranularity.component
)
}?.value
}()

return MetricsOverviewTabView.MetricData(
metric: metric,
value: value,
previousValue: nil // No comparison for legacy chart
)
}
}

var formattedCurrentDate: String {
let formatter = StatsDateFormatter()
let dateToFormat = selectedBarDate ?? currentDate
return formatter.formatDate(dateToFormat, granularity: selectedGranularity, context: .regular)
}

var currentChartData: SimpleChartData? {
chartData[selectedMetric]
}

var placeholderTabViewData: [MetricsOverviewTabView<WordAdsMetric>.MetricData] {
WordAdsMetric.allMetrics.map { metric in
.init(metric: metric, value: 12345, previousValue: nil)
}
}

// MARK: - Initialization

init(service: any StatsServiceProtocol) {
self.service = service
}

// MARK: - Public Methods

func onAppear() {
guard chartData.isEmpty else { return }
loadData()
}

func onGranularityChanged(_ newGranularity: DateRangeGranularity) {
selectedGranularity = newGranularity
currentDate = Date() // Reset to current date
selectedBarDate = nil
loadData()
}

func onBarTapped(_ date: Date) {
selectedBarDate = date
}

func onMetricSelected(_ metric: WordAdsMetric) {
selectedMetric = metric
// Chart data already loaded, no need to reload
// Select the latest period for the new metric
if let latestDate = chartData[metric]?.currentData.last?.date {
selectedBarDate = latestDate
}
}

// MARK: - Private Methods

func loadData() {
// Cancel any existing load task
loadTask?.cancel()

withAnimation {
isLoading = true
loadingError = nil
}

loadTask = Task { [weak self] in
guard let self else { return }

do {
let response = try await service.getWordAdsStats(
date: currentDate,
granularity: selectedGranularity
)

guard !Task.isCancelled else { return }

// Transform response into chart data for each metric
var newChartData: [WordAdsMetric: SimpleChartData] = [:]
for metric in WordAdsMetric.allMetrics {
if let dataPoints = response.metrics[metric] {
let total = DataPoint.getTotalValue(
for: dataPoints,
metric: metric
) ?? 0

newChartData[metric] = SimpleChartData(
metric: metric,
granularity: selectedGranularity,
currentTotal: total,
currentData: dataPoints
)
}
}

withAnimation {
self.chartData = newChartData
self.isFirstLoad = false
self.isLoading = false
}

// Automatically select the latest period if no selection exists
if self.selectedBarDate == nil, let latestDate = newChartData[self.selectedMetric]?.currentData.last?.date {
self.selectedBarDate = latestDate
}
} catch {
guard !Task.isCancelled else { return }
withAnimation {
self.loadingError = error
self.isFirstLoad = false
self.isLoading = false
}
}
}
}
}
Loading