diff --git a/Modules/Sources/JetpackStats/Analytics/StatsEvent.swift b/Modules/Sources/JetpackStats/Analytics/StatsEvent.swift index c423cec51b68..6a0dd39f4fa1 100644 --- a/Modules/Sources/JetpackStats/Analytics/StatsEvent.swift +++ b/Modules/Sources/JetpackStats/Analytics/StatsEvent.swift @@ -29,6 +29,9 @@ public enum StatsEvent { /// Subscribers tab shown case subscribersTabShown + /// Ads tab shown + case adsTabShown + /// Post details screen shown case postDetailsScreenShown diff --git a/Modules/Sources/JetpackStats/Cards/ChartCard.swift b/Modules/Sources/JetpackStats/Cards/ChartCard.swift index 6aa1729ba84c..9685ee68c913 100644 --- a/Modules/Sources/JetpackStats/Cards/ChartCard.swift +++ b/Modules/Sources/JetpackStats/Cards/ChartCard.swift @@ -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) @@ -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) diff --git a/Modules/Sources/JetpackStats/Cards/ChartCardViewModel.swift b/Modules/Sources/JetpackStats/Cards/ChartCardViewModel.swift index 298f466494ba..8d51a2ba3879 100644 --- a/Modules/Sources/JetpackStats/Cards/ChartCardViewModel.swift +++ b/Modules/Sources/JetpackStats/Cards/ChartCardViewModel.swift @@ -201,7 +201,7 @@ final class ChartCardViewModel: ObservableObject, TrafficCardViewModel { return output } - var tabViewData: [MetricsOverviewTabView.MetricData] { + var tabViewData: [MetricsOverviewTabView.MetricData] { metrics.map { metric in if let chartData = chartData[metric] { return .init( @@ -215,7 +215,7 @@ final class ChartCardViewModel: ObservableObject, TrafficCardViewModel { } } - var placeholderTabViewData: [MetricsOverviewTabView.MetricData] { + var placeholderTabViewData: [MetricsOverviewTabView.MetricData] { metrics.map { metric in .init(metric: metric, value: 12345, previousValue: 11234) } diff --git a/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift b/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift index 4bb90f0ac756..a3faad6e04c7 100644 --- a/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift +++ b/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift @@ -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) @@ -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: { diff --git a/Modules/Sources/JetpackStats/Cards/WordAdsChartCard.swift b/Modules/Sources/JetpackStats/Cards/WordAdsChartCard.swift new file mode 100644 index 000000000000..bd6f7e7fc341 --- /dev/null +++ b/Modules/Sources/JetpackStats/Cards/WordAdsChartCard.swift @@ -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) +} diff --git a/Modules/Sources/JetpackStats/Cards/WordAdsChartCardViewModel.swift b/Modules/Sources/JetpackStats/Cards/WordAdsChartCardViewModel.swift new file mode 100644 index 000000000000..d9dcbfde2e8e --- /dev/null +++ b/Modules/Sources/JetpackStats/Cards/WordAdsChartCardViewModel.swift @@ -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? + + // MARK: - Computed Properties + + var tabViewData: [MetricsOverviewTabView.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.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 + } + } + } + } +} diff --git a/Modules/Sources/JetpackStats/Cards/WordAdsEarningsTotalsCard.swift b/Modules/Sources/JetpackStats/Cards/WordAdsEarningsTotalsCard.swift new file mode 100644 index 000000000000..f1cd990eb0a2 --- /dev/null +++ b/Modules/Sources/JetpackStats/Cards/WordAdsEarningsTotalsCard.swift @@ -0,0 +1,125 @@ +import SwiftUI +import WordPressKit + +struct WordAdsEarningsTotalsCard: View { + @ObservedObject var viewModel: WordAdsEarningsViewModel + + @Environment(\.openURL) private var openURL + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + StatsCardTitleView(title: Strings.WordAds.totalEarnings) + + VStack(alignment: .leading, spacing: Constants.step1) { + Text(totalEarningsValue) + .contentTransition(.numericText()) + .font(Font.make(.recoleta, textStyle: .largeTitle, weight: .medium)) + .foregroundColor(.primary) + .animation(.spring, value: totalEarningsValue) + + HStack(spacing: Constants.step4) { + SecondaryMetricView( + title: Strings.WordAds.paid, + value: metricsData?.paid + ) + + SecondaryMetricView( + title: Strings.WordAds.outstanding, + value: metricsData?.outstanding + ) + } + } + .redacted(reason: viewModel.isFirstLoad ? .placeholder : []) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(Constants.step2) + .cardStyle() + .overlay(alignment: .topTrailing) { + moreMenu + } + } + + private var moreMenu: some View { + Menu { + Link(destination: URL(string: "https://wordpress.com/support/wordads-and-earn/track-your-ads/")!) { + Label(Strings.WordAds.learnMore, systemImage: "info.circle") + } + } label: { + Image(systemName: "ellipsis") + .font(.system(size: 15)) + .foregroundColor(.secondary) + .frame(width: 50, height: 50) + } + .tint(Color.primary) + } + + private var totalEarningsValue: String { + guard let value = metricsData?.totalEarnings else { return "–" } + return value.formatted(.currency(code: "USD")) + } + + private var metricsData: EarningsMetricsData? { + if let earnings = viewModel.earnings { + return EarningsMetricsData( + totalEarnings: earnings.totalEarnings, + paid: earnings.totalEarnings - earnings.totalAmountOwed, + outstanding: earnings.totalAmountOwed + ) + } else if viewModel.isFirstLoad { + return .mockData + } else { + return nil + } + } +} + +// MARK: - Secondary Metric View + +private struct SecondaryMetricView: View { + let title: String + let value: Decimal? + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.subheadline) + .foregroundColor(.secondary) + + Text(displayValue) + .contentTransition(.numericText()) + .font(.subheadline.weight(.medium)) + .foregroundColor(.primary) + .animation(.spring, value: displayValue) + } + .lineLimit(1) + } + + private var displayValue: String { + guard let value else { return "–" } + return value.formatted(.currency(code: "USD")) + } +} + +// MARK: - Data Models + +private struct EarningsMetricsData { + let totalEarnings: Decimal + let paid: Decimal + let outstanding: Decimal + + /// Mock data used for loading state placeholders + static let mockData = EarningsMetricsData( + totalEarnings: 42.67, + paid: 4.27, + outstanding: 38.40 + ) +} + +#Preview { + @Previewable @StateObject var viewModel = WordAdsEarningsViewModel(service: MockStatsService()) + + return VStack { + WordAdsEarningsTotalsCard(viewModel: viewModel) + } + .padding() +} diff --git a/Modules/Sources/JetpackStats/Cards/WordAdsEarningsViewModel.swift b/Modules/Sources/JetpackStats/Cards/WordAdsEarningsViewModel.swift new file mode 100644 index 000000000000..68c566f0be5c --- /dev/null +++ b/Modules/Sources/JetpackStats/Cards/WordAdsEarningsViewModel.swift @@ -0,0 +1,36 @@ +import Foundation +@preconcurrency import WordPressKit + +/// ViewModel managing state for WordAds earnings data. +@MainActor +final class WordAdsEarningsViewModel: ObservableObject { + // MARK: - Published Properties + + @Published private(set) var earnings: StatsWordAdsEarningsResponse? + @Published private(set) var isFirstLoad = true + @Published private(set) var loadingError: Error? + + // MARK: - Dependencies + + private let service: any StatsServiceProtocol + + // MARK: - Initialization + + init(service: any StatsServiceProtocol) { + self.service = service + } + + func refresh() async { + do { + let response = try await service.getWordAdsEarnings() + guard !Task.isCancelled else { return } + self.earnings = response + self.loadingError = nil + self.isFirstLoad = false + } catch { + guard !Task.isCancelled else { return } + self.loadingError = error + self.isFirstLoad = false + } + } +} diff --git a/Modules/Sources/JetpackStats/Cards/WordAdsPaymentHistoryCard.swift b/Modules/Sources/JetpackStats/Cards/WordAdsPaymentHistoryCard.swift new file mode 100644 index 000000000000..c583b6b55b06 --- /dev/null +++ b/Modules/Sources/JetpackStats/Cards/WordAdsPaymentHistoryCard.swift @@ -0,0 +1,131 @@ +import SwiftUI +@preconcurrency import WordPressKit + +struct WordAdsPaymentHistoryCard: View { + @ObservedObject var viewModel: WordAdsEarningsViewModel + + @Environment(\.router) private var router + @Environment(\.context) private var context + + private let itemLimit = 6 + + private var mockViewModels: [WordAdsPaymentHistoryRowViewModel] { + (0..<5).map { index in + WordAdsPaymentHistoryRowViewModel( + earning: StatsWordAdsEarningsResponse.MonthlyEarning( + period: StatsWordAdsEarningsResponse.Period(year: 2025, month: 12 - index), + data: StatsWordAdsEarningsResponse.MonthlyEarningData( + amount: 15.25, + status: .outstanding, + pageviews: "3420" + ) + ) + ) + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + StatsCardTitleView(title: Strings.WordAds.paymentsHistory) + .padding(.horizontal, Constants.step3) + .padding(.bottom, Constants.step1) + + if viewModel.isFirstLoad { + loadingView + } else if let earnings = viewModel.earnings, !earnings.wordAdsEarnings.isEmpty { + let rowViewModels = earnings.wordAdsEarnings.prefix(itemLimit).map { + WordAdsPaymentHistoryRowViewModel(earning: $0) + } + contentView(viewModels: Array(rowViewModels), hasMore: earnings.wordAdsEarnings.count > itemLimit) + } else if let loadingError = viewModel.loadingError { + errorView(error: loadingError) + } else { + emptyView + } + } + .padding(.vertical, Constants.step2) + .cardStyle() + } + + private var loadingView: some View { + contentView(viewModels: mockViewModels, hasMore: false) + .redacted(reason: .placeholder) + .pulsating() + } + + private var emptyView: some View { + Text(Strings.WordAds.noPaymentsYet) + .font(.subheadline) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, Constants.step3) + } + + private func errorView(error: Error) -> some View { + contentView(viewModels: mockViewModels, hasMore: false) + .redacted(reason: .placeholder) + .grayscale(1) + .opacity(0.1) + .overlay { + SimpleErrorView(error: error) + } + } + + private func contentView(viewModels: [WordAdsPaymentHistoryRowViewModel], hasMore: Bool) -> some View { + VStack(spacing: 0) { + ForEach(Array(viewModels.enumerated()), id: \.offset) { index, viewModel in + WordAdsPaymentHistoryRowView(viewModel: viewModel) + .padding(.horizontal, Constants.step3) + .padding(.vertical, 9) + + if index < viewModels.count - 1 { + Divider() + .padding(.leading, Constants.step3) + } + } + + if hasMore { + showMoreButton + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, Constants.step3) + } + } + } + + private var showMoreButton: some View { + Button { + navigateToPaymentHistory() + } label: { + HStack(spacing: 4) { + Text(Strings.Buttons.showAll) + .padding(.trailing, 4) + .font(.callout) + .foregroundColor(.primary) + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundColor(.secondary) + } + } + .padding(.top, 16) + .tint(Color.secondary.opacity(0.8)) + .dynamicTypeSize(...DynamicTypeSize.xLarge) + } + + private func navigateToPaymentHistory() { + guard let earnings = viewModel.earnings else { return } + + router.navigate( + to: WordAdsPaymentHistoryView(earnings: earnings), + title: Strings.WordAds.paymentsHistory + ) + } +} + +#Preview { + @Previewable @StateObject var viewModel = WordAdsEarningsViewModel(service: MockStatsService()) + + return VStack { + WordAdsPaymentHistoryCard(viewModel: viewModel) + } + .padding() +} diff --git a/Modules/Sources/JetpackStats/Charts/Helpers/ChartAverageAnnotation.swift b/Modules/Sources/JetpackStats/Charts/Helpers/ChartAverageAnnotation.swift index 10503e4f70a4..a610e08e59df 100644 --- a/Modules/Sources/JetpackStats/Charts/Helpers/ChartAverageAnnotation.swift +++ b/Modules/Sources/JetpackStats/Charts/Helpers/ChartAverageAnnotation.swift @@ -2,7 +2,7 @@ import SwiftUI struct ChartAverageAnnotation: View { let value: Int - let formatter: StatsValueFormatter + let formatter: any ValueFormatterProtocol @Environment(\.colorScheme) private var colorScheme diff --git a/Modules/Sources/JetpackStats/Charts/Helpers/SimpleChartData.swift b/Modules/Sources/JetpackStats/Charts/Helpers/SimpleChartData.swift new file mode 100644 index 000000000000..f6dce90d8d13 --- /dev/null +++ b/Modules/Sources/JetpackStats/Charts/Helpers/SimpleChartData.swift @@ -0,0 +1,56 @@ +import SwiftUI + +/// Simplified chart data structure for metrics without comparison period support. +final class SimpleChartData: Sendable { + let metric: any MetricType + let granularity: DateRangeGranularity + let currentTotal: Int + let currentData: [DataPoint] + let maxValue: Int + var isEmpty: Bool { + currentData.isEmpty || currentData.allSatisfy { $0.value == 0 } + } + + init( + metric: any MetricType, + granularity: DateRangeGranularity, + currentTotal: Int, + currentData: [DataPoint] + ) { + self.metric = metric + self.granularity = granularity + self.currentTotal = currentTotal + self.currentData = currentData + self.maxValue = currentData.map(\.value).max() ?? 0 + } + + /// Creates mock chart data for preview and testing purposes. + static func mock( + metric: any MetricType, + granularity: DateRangeGranularity = .day, + dataPointCount: Int = 7 + ) -> SimpleChartData { + let calendar = Calendar.current + let now = Date() + + var mockData: [DataPoint] = [] + for i in 0.. Void + + @State private var hoveredDate: Date? + @State private var isInteracting = false + + @Environment(\.context) var context + @Environment(\.colorScheme) private var colorScheme + + private var valueFormatter: any ValueFormatterProtocol { + data.metric.makeValueFormatter() + } + + private var currentAverage: Double { + guard !data.currentData.isEmpty else { return 0 } + return Double(data.currentTotal) / Double(data.currentData.count) + } + + var body: some View { + Chart { + currentPeriodBars + averageLine + peakAnnotation + selectionIndicator + } + .chartXAxis { xAxis } + .chartYAxis { yAxis } + .chartYScale(domain: yAxisDomain) + .chartLegend(.hidden) + .animation(.spring, value: ObjectIdentifier(data)) + .chartOverlay { proxy in + makeOverlayView(proxy: proxy) + } + .dynamicTypeSize(...DynamicTypeSize.xxxLarge) + } + + // MARK: - Chart Marks + + @ChartContentBuilder + private var currentPeriodBars: some ChartContent { + ForEach(data.currentData) { point in + BarMark( + x: .value("Date", point.date, unit: data.granularity.component), + y: .value("Value", point.value), + width: barWidth + ) + .foregroundStyle( + LinearGradient( + colors: [ + data.metric.primaryColor, + lighten(data.metric.primaryColor) + ], + startPoint: .top, + endPoint: .bottom + ) + ) + .cornerRadius(6) + .opacity(getBarOpacity(for: point)) + } + } + + private var barWidth: MarkDimension { + data.currentData.count <= 3 ? .fixed(32) : .automatic + } + + private func lighten(_ color: Color) -> Color { + if #available(iOS 18, *) { + color.mix(with: Color(.systemBackground), by: colorScheme == .light ? 0.4 : 0.15) + } else { + color.opacity(0.5) + } + } + + private func getBarOpacity(for point: DataPoint) -> CGFloat { + let dateToMatch = isInteracting ? hoveredDate : selectedDate + guard let dateToMatch else { return 1.0 } + + return context.calendar.isDate( + point.date, + equalTo: dateToMatch, + toGranularity: data.granularity.component + ) ? 1.0 : 0.5 + } + + @ChartContentBuilder + private var averageLine: some ChartContent { + if currentAverage > 0 { + RuleMark(y: .value("Average", currentAverage)) + .foregroundStyle(Color.secondary.opacity(0.33)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [6, 6])) + .annotation(position: .trailing, alignment: .trailing) { + ChartAverageAnnotation(value: Int(currentAverage), formatter: valueFormatter) + } + } + } + + @ChartContentBuilder + private var peakAnnotation: some ChartContent { + if let maxPoint = data.currentData.max(by: { $0.value < $1.value }), data.currentData.count > 0 { + PointMark( + x: .value("Date", maxPoint.date, unit: data.granularity.component), + y: .value("Value", maxPoint.value) + ) + .opacity(0) + .annotation(position: .top, spacing: 8) { + PeakValueAnnotation(value: maxPoint.value, metric: data.metric) + // Hide when interacting to avoid clutter + .opacity(isInteracting ? 0 : 1) + } + } + } + + @ChartContentBuilder + private var selectionIndicator: some ChartContent { + let dateToMatch = isInteracting ? hoveredDate : selectedDate + if let dateToMatch, + let selectedPoint = data.currentData.first(where: { point in + context.calendar.isDate( + point.date, + equalTo: dateToMatch, + toGranularity: data.granularity.component + ) + }) { + // Subtle vertical background fill for entire bar area + RectangleMark( + x: .value("Date", selectedPoint.date, unit: data.granularity.component), + yStart: .value("Bottom", 0), + yEnd: .value("Top", yAxisDomain.upperBound), + width: barWidth + ) + .foregroundStyle(data.metric.primaryColor.opacity(colorScheme == .light ? 0.08 : 0.12)) + .zIndex(-1) + } + } + + // MARK: - Axis Configuration + + private var xAxis: some AxisContent { + if data.currentData.count == 1 { + AxisMarks(values: .stride(by: data.granularity.component, count: 1)) { value in + if let date = value.as(Date.self) { + AxisValueLabel { + ChartAxisDateLabel(date: date, granularity: data.granularity) + } + } + } + } else { + AxisMarks(values: .automatic) { value in + if let date = value.as(Date.self) { + AxisValueLabel { + ChartAxisDateLabel(date: date, granularity: data.granularity) + } + } + } + } + } + + private var yAxis: some AxisContent { + AxisMarks(values: .automatic) { value in + if let value = value.as(Int.self) { + AxisGridLine() + .foregroundStyle(Color.secondary.opacity(0.33)) + AxisValueLabel { + if value > 0 { + Text(valueFormatter.format(value: value, context: .compact)) + .font(.caption2.weight(.medium)) + .foregroundColor(.secondary) + } + } + } + } + } + + private var yAxisDomain: ClosedRange { + // If all values are zero, show a reasonable range + if data.maxValue == 0 { + return 0...100 + } + guard data.maxValue > 0 else { + return data.maxValue...0 + } + // Add some padding above the max value + let padding = max(Int(Double(data.maxValue) * 0.33), 1) + return 0...(data.maxValue + padding) + } + + // MARK: - Gesture Handling + + private func makeOverlayView(proxy: ChartProxy) -> some View { + GeometryReader { geometry in + ChartGestureOverlay( + onTap: { location in + handleTap(at: location, proxy: proxy, geometry: geometry) + }, + onInteractionUpdate: { location in + isInteracting = true + if let date = getDate(at: location, proxy: proxy, geometry: geometry) { + hoveredDate = date + onBarTapped(date) + } + }, + onInteractionEnd: { + isInteracting = false + hoveredDate = nil + } + ) + } + } + + private func handleTap(at location: CGPoint, proxy: ChartProxy, geometry: GeometryProxy) { + guard !isInteracting, + let date = getDate(at: location, proxy: proxy, geometry: geometry) else { + return + } + onBarTapped(date) + } + + private func getDate(at location: CGPoint, proxy: ChartProxy, geometry: GeometryProxy) -> Date? { + guard let frame = proxy.plotFrame else { return nil } + + let origin = geometry[frame].origin + let adjustedX = location.x - origin.x + + return proxy.value(atX: adjustedX) + } +} + +// MARK: - Supporting Views + +private struct PeakValueAnnotation: View { + let value: Int + let metric: any MetricType + let valueFormatter: any ValueFormatterProtocol + + @Environment(\.colorScheme) private var colorScheme + + init(value: Int, metric: any MetricType) { + self.value = value + self.metric = metric + self.valueFormatter = metric.makeValueFormatter() + } + + var body: some View { + Text(valueFormatter.format(value: value, context: .compact)) + .fixedSize() + .font(.system(.caption, design: .rounded, weight: .semibold)) + .foregroundColor(metric.primaryColor) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background { + ZStack { + Capsule() + .fill(Color(.systemBackground).opacity(0.75)) + Capsule() + .fill(metric.primaryColor.opacity(colorScheme == .light ? 0.1 : 0.25)) + } + } + } +} + +// MARK: - Preview + +#Preview("Days") { + SimpleBarChartView( + data: SimpleChartData.mock(metric: WordAdsMetric.impressions, granularity: .day, dataPointCount: 7), + selectedDate: nil, + onBarTapped: { _ in } + ) + .frame(height: 180) + .padding() + .background(Constants.Colors.background) +} + +#Preview("With Selection") { + SimpleBarChartView( + data: SimpleChartData.mock(metric: WordAdsMetric.revenue, granularity: .day, dataPointCount: 7), + selectedDate: Date(), + onBarTapped: { _ in } + ) + .frame(height: 180) + .padding() + .background(Constants.Colors.background) +} + +#Preview("Months") { + SimpleBarChartView( + data: SimpleChartData.mock(metric: WordAdsMetric.cpm, granularity: .month, dataPointCount: 12), + selectedDate: nil, + onBarTapped: { _ in } + ) + .frame(height: 180) + .padding() + .background(Constants.Colors.background) +} diff --git a/Modules/Sources/JetpackStats/Extensions/Calendar+Navigation.swift b/Modules/Sources/JetpackStats/Extensions/Calendar+Navigation.swift index 815c35688567..27456a01de2c 100644 --- a/Modules/Sources/JetpackStats/Extensions/Calendar+Navigation.swift +++ b/Modules/Sources/JetpackStats/Extensions/Calendar+Navigation.swift @@ -1,18 +1,25 @@ import Foundation -extension Calendar { - enum NavigationDirection { - case backward - case forward - - var systemImage: String { - switch self { - case .backward: "chevron.backward" - case .forward: "chevron.forward" - } +enum NavigationDirection { + case backward + case forward + + var systemImage: String { + switch self { + case .backward: "chevron.backward" + case .forward: "chevron.forward" } } + var accessibilityLabel: String { + switch self { + case .forward: Strings.Accessibility.nextPeriod + case .backward: Strings.Accessibility.previousPeriod + } + } +} + +extension Calendar { /// Navigates to the next or previous period from the given date interval. /// /// This method navigates by the length of the period for the given component. diff --git a/Modules/Sources/JetpackStats/Screens/AdsTabView.swift b/Modules/Sources/JetpackStats/Screens/AdsTabView.swift index 2a7464a96063..8d290e1a51ff 100644 --- a/Modules/Sources/JetpackStats/Screens/AdsTabView.swift +++ b/Modules/Sources/JetpackStats/Screens/AdsTabView.swift @@ -1,28 +1,58 @@ import SwiftUI +import WordPressKit +import WordPressUI public struct AdsTabView: View { + @StateObject private var chartViewModel: WordAdsChartCardViewModel + @StateObject private var earningsViewModel: WordAdsEarningsViewModel - public init() {} + @Environment(\.horizontalSizeClass) var horizontalSizeClass - public var body: some View { - ScrollView { - VStack(spacing: 16) { - Text("Ads") - .font(.largeTitle) - .fontWeight(.bold) - .padding(.top, 20) + private let context: StatsContext + private let router: StatsRouter - Text("Coming Soon") - .font(.headline) - .foregroundColor(.secondary) + public init(context: StatsContext, router: StatsRouter) { + self.context = context + self.router = router + _chartViewModel = StateObject( + wrappedValue: WordAdsChartCardViewModel(service: context.service) + ) + _earningsViewModel = StateObject( + wrappedValue: WordAdsEarningsViewModel(service: context.service) + ) + } - Spacer(minLength: 100) + public var body: some View { + ScrollView { + VStack(spacing: Constants.step3) { + WordAdsEarningsTotalsCard(viewModel: earningsViewModel) + WordAdsChartCard(viewModel: chartViewModel) + WordAdsPaymentHistoryCard(viewModel: earningsViewModel) } - .padding() + .padding(.vertical, Constants.step2) + .padding(.horizontal, Constants.cardHorizontalInset(for: horizontalSizeClass)) + .padding(.top, Constants.step0_5) + } + .background(Constants.Colors.background) + .environment(\.context, context) + .environment(\.router, router) + .onAppear { + context.tracker?.send(.adsTabShown) + } + .task { + await earningsViewModel.refresh() } } } #Preview { - AdsTabView() + NavigationStack { + AdsTabView( + context: .demo, + router: StatsRouter( + viewController: UINavigationController(), + factory: MockStatsRouterScreenFactory() + ) + ) + } } diff --git a/Modules/Sources/JetpackStats/Screens/AuthorStatsView.swift b/Modules/Sources/JetpackStats/Screens/AuthorStatsView.swift index 83e789ca5dc2..0cbb37c92fd2 100644 --- a/Modules/Sources/JetpackStats/Screens/AuthorStatsView.swift +++ b/Modules/Sources/JetpackStats/Screens/AuthorStatsView.swift @@ -138,7 +138,7 @@ struct AuthorStatsView: View { let trend = TrendViewModel( currentValue: current, previousValue: previous, - metric: .views + metric: SiteMetric.views ) HStack(spacing: 4) { diff --git a/Modules/Sources/JetpackStats/Screens/StatsMainView.swift b/Modules/Sources/JetpackStats/Screens/StatsMainView.swift index 4a7ba65aa5d9..0aaa20fd5ae2 100644 --- a/Modules/Sources/JetpackStats/Screens/StatsMainView.swift +++ b/Modules/Sources/JetpackStats/Screens/StatsMainView.swift @@ -67,7 +67,7 @@ public struct StatsMainView: View { context.tracker?.send(.subscribersTabShown) } case .ads: - AdsTabView() + AdsTabView(context: context, router: router) } } diff --git a/Modules/Sources/JetpackStats/Screens/WordAdsPaymentHistoryView.swift b/Modules/Sources/JetpackStats/Screens/WordAdsPaymentHistoryView.swift new file mode 100644 index 000000000000..c7bf896ecddf --- /dev/null +++ b/Modules/Sources/JetpackStats/Screens/WordAdsPaymentHistoryView.swift @@ -0,0 +1,67 @@ +import SwiftUI +@preconcurrency import WordPressKit + +struct WordAdsPaymentHistoryView: View { + let earnings: StatsWordAdsEarningsResponse + + @Environment(\.context) private var context + + var body: some View { + List { + ForEach(groupedEarningsByYear, id: \.year) { group in + Section(header: Text(String(group.year))) { + ForEach(group.earnings, id: \.period) { earning in + WordAdsPaymentHistoryRowView(viewModel: WordAdsPaymentHistoryRowViewModel(earning: earning)) + .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) + } + } + } + } + .listStyle(.plain) + } + + private var groupedEarningsByYear: [YearGroup] { + let grouped = Dictionary(grouping: earnings.wordAdsEarnings) { $0.period.year } + return grouped.map { YearGroup(year: $0.key, earnings: $0.value) } + .sorted { $0.year > $1.year } + } + + private struct YearGroup { + let year: Int + let earnings: [StatsWordAdsEarningsResponse.MonthlyEarning] + } +} + +#Preview { + let json = """ + { + "ID": 123456, + "name": "Test Site", + "URL": "https://test.com", + "earnings": { + "total_earnings": "150.00", + "total_amount_owed": "120.00", + "wordads": { + "2025-12": {"amount": 25.50, "status": "0", "pageviews": "4500"}, + "2025-11": {"amount": 20.30, "status": "0", "pageviews": "3800"}, + "2025-10": {"amount": 18.75, "status": "1", "pageviews": "3200"}, + "2025-09": {"amount": 15.20, "status": "1", "pageviews": "2900"}, + "2025-08": {"amount": 12.80, "status": "1", "pageviews": "2500"}, + "2025-07": {"amount": 11.50, "status": "1", "pageviews": "2300"}, + "2024-12": {"amount": 10.20, "status": "1", "pageviews": "2100"}, + "2024-11": {"amount": 9.80, "status": "1", "pageviews": "1900"}, + "2024-10": {"amount": 8.50, "status": "1", "pageviews": "1700"}, + "2024-09": {"amount": 7.20, "status": "1", "pageviews": "1500"} + } + } + } + """ + let earnings = try! JSONDecoder().decode(StatsWordAdsEarningsResponse.self, from: json.data(using: .utf8)!) + + return NavigationStack { + WordAdsPaymentHistoryView(earnings: earnings) + .navigationTitle(Strings.WordAds.paymentsHistory) + .navigationBarTitleDisplayMode(.inline) + } + .environment(\.context, .demo) +} diff --git a/Modules/Sources/JetpackStats/Services/Data/DataPoint.swift b/Modules/Sources/JetpackStats/Services/Data/DataPoint.swift index f51883c87c52..e4c576d68ab8 100644 --- a/Modules/Sources/JetpackStats/Services/Data/DataPoint.swift +++ b/Modules/Sources/JetpackStats/Services/Data/DataPoint.swift @@ -28,7 +28,7 @@ extension DataPoint { }.reversed() } - static func getTotalValue(for dataPoints: [DataPoint], metric: SiteMetric) -> Int? { + static func getTotalValue(for dataPoints: [DataPoint], metric: some MetricType) -> Int? { guard !dataPoints.isEmpty else { return nil } diff --git a/Modules/Sources/JetpackStats/Services/Data/MetricType.swift b/Modules/Sources/JetpackStats/Services/Data/MetricType.swift new file mode 100644 index 000000000000..7eb4f3203f04 --- /dev/null +++ b/Modules/Sources/JetpackStats/Services/Data/MetricType.swift @@ -0,0 +1,20 @@ +import SwiftUI + +/// Protocol defining the requirements for a metric type that can be displayed in stats views. +protocol MetricType: Identifiable, Hashable, Equatable, Sendable { + var localizedTitle: String { get } + var systemImage: String { get } + var primaryColor: Color { get } + var isHigherValueBetter: Bool { get } + var aggregationStrategy: AggregationStrategy { get } + + /// Creates the appropriate value formatter for this metric type. + func makeValueFormatter() -> any ValueFormatterProtocol +} + +enum AggregationStrategy: Sendable { + /// Simply sum the values for the given period. + case sum + /// Calculate the average value for the given period. + case average +} diff --git a/Modules/Sources/JetpackStats/Services/Data/SiteMetric.swift b/Modules/Sources/JetpackStats/Services/Data/SiteMetric.swift index 88b19fc6a116..d997ddf7de94 100644 --- a/Modules/Sources/JetpackStats/Services/Data/SiteMetric.swift +++ b/Modules/Sources/JetpackStats/Services/Data/SiteMetric.swift @@ -1,6 +1,6 @@ import SwiftUI -enum SiteMetric: String, CaseIterable, Identifiable, Sendable, Codable { +enum SiteMetric: String, CaseIterable, Identifiable, Sendable, Codable, MetricType { case views case visitors case likes @@ -75,10 +75,7 @@ extension SiteMetric { } } - enum AggregationStrategy { - /// Simply sum the values for the given period. - case sum - /// Calculate the avarege value for the given period. - case average + func makeValueFormatter() -> any ValueFormatterProtocol { + StatsValueFormatter(metric: self) } } diff --git a/Modules/Sources/JetpackStats/Services/Data/WordAdsMetric.swift b/Modules/Sources/JetpackStats/Services/Data/WordAdsMetric.swift new file mode 100644 index 000000000000..a6e5b1a3e06a --- /dev/null +++ b/Modules/Sources/JetpackStats/Services/Data/WordAdsMetric.swift @@ -0,0 +1,70 @@ +import SwiftUI + +struct WordAdsMetric: Identifiable, Sendable, Hashable, MetricType { + let id: String + let localizedTitle: String + let systemImage: String + let primaryColor: Color + let aggregationStrategy: AggregationStrategy + let isHigherValueBetter: Bool + + private init( + id: String, + localizedTitle: String, + systemImage: String, + primaryColor: Color, + aggregationStrategy: AggregationStrategy, + isHigherValueBetter: Bool = true + ) { + self.id = id + self.localizedTitle = localizedTitle + self.systemImage = systemImage + self.primaryColor = primaryColor + self.aggregationStrategy = aggregationStrategy + self.isHigherValueBetter = isHigherValueBetter + } + + func backgroundColor(in colorScheme: ColorScheme) -> Color { + primaryColor.opacity(colorScheme == .light ? 0.05 : 0.15) + } + + static func == (lhs: WordAdsMetric, rhs: WordAdsMetric) -> Bool { + lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + func makeValueFormatter() -> any ValueFormatterProtocol { + WordAdsValueFormatter(metric: self) + } + + // MARK: - Static Metrics + + static let impressions = WordAdsMetric( + id: "impressions", + localizedTitle: Strings.WordAdsMetrics.adsServed, + systemImage: "eye", + primaryColor: Constants.Colors.blue, + aggregationStrategy: .sum + ) + + static let cpm = WordAdsMetric( + id: "cpm", + localizedTitle: Strings.WordAdsMetrics.averageCPM, + systemImage: "chart.bar", + primaryColor: Constants.Colors.celadon, + aggregationStrategy: .average + ) + + static let revenue = WordAdsMetric( + id: "revenue", + localizedTitle: Strings.WordAdsMetrics.revenue, + systemImage: "dollarsign.circle", + primaryColor: Constants.Colors.green, + aggregationStrategy: .sum + ) + + static let allMetrics: [WordAdsMetric] = [.impressions, .cpm, .revenue] +} diff --git a/Modules/Sources/JetpackStats/Services/Data/WordAdsMetricsResponse.swift b/Modules/Sources/JetpackStats/Services/Data/WordAdsMetricsResponse.swift new file mode 100644 index 000000000000..c2341006610b --- /dev/null +++ b/Modules/Sources/JetpackStats/Services/Data/WordAdsMetricsResponse.swift @@ -0,0 +1,13 @@ +import Foundation + +struct WordAdsMetricsResponse: Sendable { + var total: WordAdsMetricsSet + + /// Data points with the requested granularity. + /// + /// - note: The dates are in the site reporting time zone. + /// + /// - warning: Hourly data is not available for some metrics, but total + /// metrics still are. + var metrics: [WordAdsMetric: [DataPoint]] +} diff --git a/Modules/Sources/JetpackStats/Services/Data/WordAdsMetricsSet.swift b/Modules/Sources/JetpackStats/Services/Data/WordAdsMetricsSet.swift new file mode 100644 index 000000000000..02196e9f991e --- /dev/null +++ b/Modules/Sources/JetpackStats/Services/Data/WordAdsMetricsSet.swift @@ -0,0 +1,35 @@ +import Foundation + +/// A memory-efficient collection of WordAds metrics with direct memory layout. +struct WordAdsMetricsSet: Codable, Sendable { + var impressions: Int? + var cpm: Int? // Stored in cents + var revenue: Int? // Stored in cents + + subscript(metric: WordAdsMetric) -> Int? { + get { + switch metric.id { + case "impressions": impressions + case "cpm": cpm + case "revenue": revenue + default: nil + } + } + set { + switch metric.id { + case "impressions": impressions = newValue + case "cpm": cpm = newValue + case "revenue": revenue = newValue + default: break + } + } + } + + static var mock: WordAdsMetricsSet { + WordAdsMetricsSet( + impressions: Int.random(in: 1000...10000), + cpm: Int.random(in: 100...500), // $1.00 - $5.00 in cents + revenue: Int.random(in: 1000...10000) // $10.00 - $100.00 in cents + ) + } +} diff --git a/Modules/Sources/JetpackStats/Services/Extensions/WordPressKit+Extensions.swift b/Modules/Sources/JetpackStats/Services/Extensions/WordPressKit+Extensions.swift new file mode 100644 index 000000000000..21e60645388f --- /dev/null +++ b/Modules/Sources/JetpackStats/Services/Extensions/WordPressKit+Extensions.swift @@ -0,0 +1,143 @@ +@preconcurrency import WordPressKit + +extension WordPressKit.StatsServiceRemoteV2 { + /// A modern variant of `WordPressKit.StatsTimeIntervalData` API that supports + /// custom date periods. + func getData( + interval: DateInterval, + unit: WordPressKit.StatsPeriodUnit, + summarize: Bool? = nil, + limit: Int, + parameters: [String: String]? = nil + ) async throws -> TimeStatsType where TimeStatsType: Sendable { + try await withCheckedThrowingContinuation { continuation in + // `period` is ignored if you pass `startDate`, but it's a required parameter + getData(for: unit, unit: unit, startDate: interval.start, endingOn: interval.end, limit: limit, summarize: summarize, parameters: parameters) { (data: TimeStatsType?, error: Error?) in + if let data { + continuation.resume(returning: data) + } else { + continuation.resume(throwing: error ?? StatsServiceError.unknown) + } + } + } + } + + /// A legacy variant of `WordPressKit.StatsTimeIntervalData` API that supports + /// only support setting the target date and the quantity of periods to return. + func getData( + date: Date, + unit: WordPressKit.StatsPeriodUnit, + quantity: Int + ) async throws -> TimeStatsType where TimeStatsType: Sendable { + try await withCheckedThrowingContinuation { continuation in + // Call getData with date and quantity (quantity is passed as limit, which becomes maxCount in queryProperties) + getData(for: unit, endingOn: date, limit: quantity) { (data: TimeStatsType?, error: Error?) in + if let data { + continuation.resume(returning: data) + } else { + continuation.resume(throwing: error ?? StatsServiceError.unknown) + } + } + } + } + + func getInsight(limit: Int = 10) async throws -> InsightType where InsightType: Sendable { + try await withCheckedThrowingContinuation { continuation in + getInsight(limit: limit) { (insight: InsightType?, error: Error?) in + if let insight { + continuation.resume(returning: insight) + } else { + continuation.resume(throwing: error ?? StatsServiceError.unknown) + } + } + } + } + + func getDetails(forPostID postID: Int) async throws -> StatsPostDetails { + try await withCheckedThrowingContinuation { continuation in + getDetails(forPostID: postID) { (details: StatsPostDetails?, error: Error?) in + if let details { + continuation.resume(returning: details) + } else { + continuation.resume(throwing: error ?? StatsServiceError.unknown) + } + } + } + } + + func getInsight(limit: Int = 10) async throws -> StatsLastPostInsight { + try await withCheckedThrowingContinuation { continuation in + getInsight(limit: limit) { (insight: StatsLastPostInsight?, error: Error?) in + if let insight { + continuation.resume(returning: insight) + } else { + continuation.resume(throwing: error ?? StatsServiceError.unknown) + } + } + } + } + + func toggleSpamState(for referrerDomain: String, currentValue: Bool) async throws { + try await withCheckedThrowingContinuation { continuation in + toggleSpamState(for: referrerDomain, currentValue: currentValue, success: { + continuation.resume() + }, failure: { error in + continuation.resume(throwing: error) + }) + } + } + + func getEmailSummaryData( + quantity: Int, + sortField: StatsEmailsSummaryData.SortField = .opens, + sortOrder: StatsEmailsSummaryData.SortOrder = .descending + ) async throws -> StatsEmailsSummaryData { + try await withCheckedThrowingContinuation { continuation in + getData(quantity: quantity, sortField: sortField, sortOrder: sortOrder) { result in + switch result { + case .success(let data): + continuation.resume(returning: data) + case .failure(let error): + continuation.resume(throwing: error) + } + } + } + } + + func getEmailOpens(for postID: Int) async throws -> StatsEmailOpensData { + try await withCheckedThrowingContinuation { continuation in + getEmailOpens(for: postID) { (data, error) in + if let data { + continuation.resume(returning: data) + } else { + continuation.resume(throwing: error ?? StatsServiceError.unknown) + } + } + } + } +} + +extension WordPressKit.StatsPeriodUnit { + init(_ granularity: DateRangeGranularity) { + switch granularity { + case .hour: self = .hour + case .day: self = .day + case .week: self = .week + case .month: self = .month + case .year: self = .year + } + } +} + +extension WordPressKit.StatsSiteMetricsResponse.Metric { + init?(_ metric: SiteMetric) { + switch metric { + case .views: self = .views + case .visitors: self = .visitors + case .likes: self = .likes + case .comments: self = .comments + case .posts: self = .posts + case .timeOnSite, .bounceRate, .downloads: return nil + } + } +} diff --git a/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift b/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift index cd763ac12f00..69467db1931a 100644 --- a/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift +++ b/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift @@ -4,6 +4,7 @@ import SwiftUI actor MockStatsService: ObservableObject, StatsServiceProtocol { private var hourlyData: [SiteMetric: [DataPoint]] = [:] + private var wordAdsHourlyData: [WordAdsMetric: [DataPoint]] = [:] private var dailyTopListData: [TopListItemType: [Date: [any TopListItemProtocol]]] = [:] private let calendar: Calendar @@ -40,6 +41,7 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { return } await generateChartMockData() + await generateWordAdsMockData() await generateTopListMockData() } @@ -79,6 +81,58 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { return SiteMetricsResponse(total: total, metrics: output) } + func getWordAdsStats(date: Date, granularity: DateRangeGranularity) async throws -> WordAdsMetricsResponse { + await generateDataIfNeeded() + + // Calculate interval: from (date - quantity*units) to date + guard let startDate = calendar.date(byAdding: granularity.component, value: -granularity.preferredQuantity, to: date) else { + throw URLError(.unknown) + } + let interval = DateInterval(start: startDate, end: date) + + var output: [WordAdsMetric: [DataPoint]] = [:] + + let aggregator = StatsDataAggregator(calendar: calendar) + + let wordAdsMetrics: [WordAdsMetric] = [.impressions, .cpm, .revenue] + + for metric in wordAdsMetrics { + guard let allDataPoints = wordAdsHourlyData[metric] else { continue } + + // Filter data points for the period + let filteredDataPoints = allDataPoints.filter { + interval.start <= $0.date && $0.date < interval.end + } + + // Use processPeriod to aggregate and normalize the data + let periodData = aggregator.processPeriod( + dataPoints: filteredDataPoints, + dateInterval: interval, + granularity: granularity, + metric: metric + ) + output[metric] = periodData.dataPoints + } + + // Calculate totals as Int (values already stored in cents) + let totalAdsServed = output[.impressions]?.reduce(0) { $0 + $1.value } ?? 0 + let totalRevenue = output[.revenue]?.reduce(0) { $0 + $1.value } ?? 0 + let cpmValues = output[.cpm]?.filter { $0.value > 0 }.map { $0.value } ?? [] + let averageCPM = cpmValues.isEmpty ? 0 : cpmValues.reduce(0, +) / cpmValues.count + + let total = WordAdsMetricsSet( + impressions: totalAdsServed, + cpm: averageCPM, + revenue: totalRevenue + ) + + if !delaysDisabled { + try? await Task.sleep(for: .milliseconds(Int.random(in: 200...500))) + } + + return WordAdsMetricsResponse(total: total, metrics: output) + } + func getTopListData(_ item: TopListItemType, metric: SiteMetric, interval: DateInterval, granularity: DateRangeGranularity, limit: Int?, locationLevel: LocationLevel?) async throws -> TopListResponse { await generateDataIfNeeded() @@ -345,6 +399,76 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { ) } + func getWordAdsEarnings() async throws -> WordPressKit.StatsWordAdsEarningsResponse { + // Simulate network delay + if !delaysDisabled { + try? await Task.sleep(for: .milliseconds(Int.random(in: 200...500))) + } + + // Generate mock earnings data for the last 12 months + let now = Date() + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM" + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + + var wordadsDict: [String: [String: Any]] = [:] + var totalEarnings: Double = 0 + var totalAmountOwed: Double = 0 + + // Generate earnings for last 12 months + for monthsAgo in 0..<12 { + guard let monthDate = calendar.date(byAdding: .month, value: -monthsAgo, to: now) else { + continue + } + + let period = dateFormatter.string(from: monthDate) + + // Generate realistic earnings that increase over time + let baseAmount = Double.random(in: 2000...5000) + let growthFactor = 1.0 + (Double(12 - monthsAgo) * 0.08) // More recent months earn more + let amount = baseAmount * growthFactor + + totalEarnings += amount + + // Months older than 2 months are paid, recent months are outstanding + let isPaid = monthsAgo > 2 + let status = isPaid ? "1" : "0" + + if !isPaid { + totalAmountOwed += amount + } + + // Generate realistic pageviews + let basePageviews = Int.random(in: 50...500) + let pageviewsGrowth = 1.0 + (Double(12 - monthsAgo) * 0.1) + let pageviews = Int(Double(basePageviews) * pageviewsGrowth) + + wordadsDict[period] = [ + "amount": amount, + "status": status, + "pageviews": String(pageviews) + ] + } + + let jsonDictionary: [String: Any] = [ + "ID": 238291108, + "name": "Mock Site", + "URL": "https://mocksite.wordpress.com", + "earnings": [ + "total_earnings": String(format: "%.2f", totalEarnings), + "total_amount_owed": String(format: "%.2f", totalAmountOwed), + "wordads": wordadsDict, + "sponsored": [], + "adjustment": [] + ] + ] + + let jsonData = try JSONSerialization.data(withJSONObject: jsonDictionary) + let response = try JSONDecoder().decode(WordPressKit.StatsWordAdsEarningsResponse.self, from: jsonData) + + return response + } + // MARK: - Data Loading /// Loads historical items from JSON files based on the data type @@ -491,6 +615,73 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { } } + private func generateWordAdsMockData() async { + let endDate = Date() + + // Create a date for Nov 1, 2011 + var dateComponents = DateComponents() + dateComponents.year = 2011 + dateComponents.month = 11 + dateComponents.day = 1 + + let startDate = calendar.date(from: dateComponents)! + + var adsServedPoints: [DataPoint] = [] + var revenuePoints: [DataPoint] = [] + var cpmPoints: [DataPoint] = [] + + var currentDate = startDate + let nowDate = Date() + while currentDate <= endDate && currentDate <= nowDate { + let components = calendar.dateComponents([.year, .month, .weekday, .hour], from: currentDate) + let hour = components.hour! + let dayOfWeek = components.weekday! + let month = components.month! + let year = components.year! + + // Base values and growth factors + let yearsSince2011 = year - 2011 + let growthFactor = 1.0 + (Double(yearsSince2011) * 0.15) + + // Recent period boost + let recentBoost = calculateRecentBoost(for: currentDate) + + // Seasonal factor + let seasonalFactor = 1.0 + 0.2 * sin(2.0 * .pi * (Double(month - 3) / 12.0)) + + // Day of week factor + let weekendFactor = (dayOfWeek == 1 || dayOfWeek == 7) ? 0.7 : 1.0 + + // Hour of day factor + let hourFactor = 0.5 + 0.5 * sin(2.0 * .pi * (Double(hour - 9) / 24.0)) + + // Random variation + let randomFactor = Double.random(in: 0.8...1.2) + + let combinedFactor = growthFactor * recentBoost * seasonalFactor * weekendFactor * randomFactor * hourFactor + + // Ads Served (impressions) + let adsServed = Int(200 * combinedFactor) + adsServedPoints.append(DataPoint(date: currentDate, value: adsServed)) + + // CPM (stored in cents) + let baseCPM = 2.5 // $2.50 + let cpmVariation = Double.random(in: 0.7...1.3) + let cpm = Int((baseCPM * growthFactor * cpmVariation) * 100) + cpmPoints.append(DataPoint(date: currentDate, value: cpm)) + + // Revenue (stored in cents, calculated from impressions and CPM) + let revenue = Int(Double(adsServed) * (Double(cpm) / 100.0) / 1000.0 * 100) + revenuePoints.append(DataPoint(date: currentDate, value: revenue)) + + currentDate = calendar.date(byAdding: .hour, value: 1, to: currentDate)! + } + + wordAdsHourlyData[WordAdsMetric.impressions] = adsServedPoints + wordAdsHourlyData[WordAdsMetric.revenue] = revenuePoints + wordAdsHourlyData[WordAdsMetric.cpm] = cpmPoints + } + private var memoizedDateComponents: [Date: DateComponents] = [:] private func generateRealisticValue(for metric: SiteMetric, at date: Date) -> Int { diff --git a/Modules/Sources/JetpackStats/Services/Mocks/StatsDataAggregator.swift b/Modules/Sources/JetpackStats/Services/Mocks/StatsDataAggregator.swift index d2dedbc982d1..fedca5365bf5 100644 --- a/Modules/Sources/JetpackStats/Services/Mocks/StatsDataAggregator.swift +++ b/Modules/Sources/JetpackStats/Services/Mocks/StatsDataAggregator.swift @@ -40,7 +40,7 @@ struct StatsDataAggregator { /// Aggregates data points based on the given granularity and normalizes for the specified metric. /// This combines the previous aggregate and normalizeForMetric functions for efficiency. - func aggregate(_ dataPoints: [DataPoint], granularity: DateRangeGranularity, metric: SiteMetric) -> [Date: Int] { + func aggregate(_ dataPoints: [DataPoint], granularity: DateRangeGranularity, metric: some MetricType) -> [Date: Int] { var aggregatedData: [Date: AggregatedDataPoint] = [:] // First pass: aggregate data @@ -54,7 +54,7 @@ struct StatsDataAggregator { } } - // Second pass: normalize based on metric strategy + // Second pass: normalize based on aggregation strategy var normalizedData: [Date: Int] = [:] for (date, dataPoint) in aggregatedData { switch metric.aggregationStrategy { @@ -99,7 +99,7 @@ struct StatsDataAggregator { dataPoints: [DataPoint], dateInterval: DateInterval, granularity: DateRangeGranularity, - metric: SiteMetric + metric: some MetricType ) -> PeriodData { // Aggregate and normalize data in one pass let normalizedData = aggregate(dataPoints, granularity: granularity, metric: metric) diff --git a/Modules/Sources/JetpackStats/Services/StatsService.swift b/Modules/Sources/JetpackStats/Services/StatsService.swift index fb63a734b3bc..0890d8e9764e 100644 --- a/Modules/Sources/JetpackStats/Services/StatsService.swift +++ b/Modules/Sources/JetpackStats/Services/StatsService.swift @@ -17,6 +17,7 @@ actor StatsService: StatsServiceProtocol { // Cache private var siteStatsCache: [SiteStatsCacheKey: CachedEntity] = [:] + private var wordAdsStatsCache: [WordAdsStatsCacheKey: CachedEntity] = [:] private var topListCache: [TopListCacheKey: CachedEntity] = [:] private let currentPeriodTTL: TimeInterval = 30 // 30 seconds for current period @@ -97,6 +98,83 @@ actor StatsService: StatsServiceProtocol { } } + func getWordAdsStats(date: Date, granularity: DateRangeGranularity) async throws -> WordAdsMetricsResponse { + // Check cache first + let cacheKey = WordAdsStatsCacheKey(date: date, granularity: granularity) + + if let cached = wordAdsStatsCache[cacheKey], !cached.isExpired { + return cached.data + } + + // Fetch fresh data + let data = try await fetchWordAdsStats(date: date, granularity: granularity) + + // Cache the result + // Historical data never expires (ttl = nil), current period data expires after 30 seconds + let ttl = dateIsToday(date) ? currentPeriodTTL : nil + + wordAdsStatsCache[cacheKey] = CachedEntity(data: data, timestamp: Date(), ttl: ttl) + + return data + } + + func getWordAdsEarnings() async throws -> WordPressKit.StatsWordAdsEarningsResponse { + try await service.getWordAdsEarnings() + } + + private func fetchWordAdsStats(date: Date, granularity: DateRangeGranularity) async throws -> WordAdsMetricsResponse { + let localDate = convertDateSiteToLocal(date) + + let response: WordPressKit.StatsWordAdsResponse = try await service.getData( + date: localDate, + unit: .init(granularity), + quantity: granularity.preferredQuantity + ) + + return mapWordAdsResponse(response) + } + + private func mapWordAdsResponse(_ response: WordPressKit.StatsWordAdsResponse) -> WordAdsMetricsResponse { + var calendar = Calendar.current + calendar.timeZone = siteTimeZone + + let now = Date.now + + func makeDataPoint(from data: WordPressKit.StatsWordAdsResponse.PeriodData, metric: WordPressKit.StatsWordAdsResponse.Metric) -> DataPoint? { + guard let value = data[metric] else { + return nil + } + let date = convertDateToSiteTimezone(data.date, using: calendar) + guard date <= now else { + return nil // Filter out future dates + } + // Store revenue and CPM in cents to use Int for DataPoint. + // The revenue is always in US dollars. + let intValue = metric == .impressions ? Int(value) : Int(value * 100) + return DataPoint(date: date, value: intValue) + } + + var total = WordAdsMetricsSet() + var metrics: [WordAdsMetric: [DataPoint]] = [:] + + // Map WordPressKit metrics to WordAdsMetric + let metricMapping: [(WordAdsMetric, WordPressKit.StatsWordAdsResponse.Metric)] = [ + (.impressions, .impressions), + (.cpm, .cpm), + (.revenue, .revenue) + ] + + for (wordAdsMetric, wpKitMetric) in metricMapping { + let dataPoints = response.data.compactMap { + makeDataPoint(from: $0, metric: wpKitMetric) + } + metrics[wordAdsMetric] = dataPoints + total[wordAdsMetric] = DataPoint.getTotalValue(for: dataPoints, metric: wordAdsMetric) + } + + return WordAdsMetricsResponse(total: total, metrics: metrics) + } + func getTopListData(_ item: TopListItemType, metric: SiteMetric, interval: DateInterval, granularity: DateRangeGranularity, limit: Int?, locationLevel: LocationLevel?) async throws -> TopListResponse { // Check cache first let cacheKey = TopListCacheKey(item: item, metric: metric, locationLevel: locationLevel, interval: interval, granularity: granularity, limit: limit) @@ -324,6 +402,13 @@ actor StatsService: StatsServiceProtocol { return interval.end >= startOfToday && interval.start < endOfToday } + /// Checks if the given date is today in the site's timezone + private func dateIsToday(_ date: Date) -> Bool { + var calendar = Calendar.current + calendar.timeZone = siteTimeZone + return calendar.isDateInToday(date) + } + /// Convert from the site timezone (used in JetpackState) to the local /// timezone (expected by WordPressKit) while preserving the date components. private func convertDateSiteToLocal(_ date: Date) -> Date { @@ -348,6 +433,21 @@ actor StatsService: StatsServiceProtocol { return dateFormatter } + /// Converts a date from local timezone to site timezone while preserving date components. + /// - Parameters: + /// - date: The date to convert + /// - calendar: The calendar to use for conversion (should have siteTimeZone set) + /// - Returns: The converted date in site timezone + private func convertDateToSiteTimezone(_ date: Date, using calendar: Calendar) -> Date { + var components = calendar.dateComponents(in: TimeZone.current, from: date) + components.timeZone = siteTimeZone + guard let output = calendar.date(from: components) else { + wpAssertionFailure("failed to convert date to site time zone", userInfo: ["date": date]) + return date + } + return output + } + private func mapSiteMetricsResponse(_ response: WordPressKit.StatsSiteMetricsResponse) -> SiteMetricsResponse { var calendar = Calendar.current calendar.timeZone = siteTimeZone @@ -358,15 +458,7 @@ actor StatsService: StatsServiceProtocol { guard let value = data[metric] else { return nil } - let date: Date = { - var components = calendar.dateComponents(in: TimeZone.current, from: data.date) - components.timeZone = siteTimeZone - guard let output = calendar.date(from: components) else { - wpAssertionFailure("failed to convert date to site time zone", userInfo: ["date": data.date]) - return data.date - } - return output - }() + let date = convertDateToSiteTimezone(data.date, using: calendar) guard date <= now else { return nil // Filter out future dates } @@ -404,6 +496,11 @@ private struct SiteStatsCacheKey: Hashable { let granularity: DateRangeGranularity } +private struct WordAdsStatsCacheKey: Hashable { + let date: Date + let granularity: DateRangeGranularity +} + private struct CachedEntity { let data: T let timestamp: Date @@ -425,128 +522,3 @@ private struct TopListCacheKey: Hashable { let granularity: DateRangeGranularity let limit: Int? } - -// MARK: - Mapping - -private extension WordPressKit.StatsPeriodUnit { - init(_ granularity: DateRangeGranularity) { - switch granularity { - case .hour: self = .hour - case .day: self = .day - case .week: self = .week - case .month: self = .month - case .year: self = .year - } - } -} - -private extension WordPressKit.StatsSiteMetricsResponse.Metric { - init?(_ metric: SiteMetric) { - switch metric { - case .views: self = .views - case .visitors: self = .visitors - case .likes: self = .likes - case .comments: self = .comments - case .posts: self = .posts - case .timeOnSite, .bounceRate, .downloads: return nil - } - } -} - -// MARK: - StatsServiceRemoteV2 Async Extensions - -private extension WordPressKit.StatsServiceRemoteV2 { - func getData( - interval: DateInterval, - unit: WordPressKit.StatsPeriodUnit, - summarize: Bool? = nil, - limit: Int, - parameters: [String: String]? = nil - ) async throws -> TimeStatsType where TimeStatsType: Sendable { - try await withCheckedThrowingContinuation { continuation in - // `period` is ignored if you pass `startDate`, but it's a required parameter - getData(for: unit, unit: unit, startDate: interval.start, endingOn: interval.end, limit: limit, summarize: summarize, parameters: parameters) { (data: TimeStatsType?, error: Error?) in - if let data { - continuation.resume(returning: data) - } else { - continuation.resume(throwing: error ?? StatsServiceError.unknown) - } - } - } - } - - func getInsight(limit: Int = 10) async throws -> InsightType where InsightType: Sendable { - try await withCheckedThrowingContinuation { continuation in - getInsight(limit: limit) { (insight: InsightType?, error: Error?) in - if let insight { - continuation.resume(returning: insight) - } else { - continuation.resume(throwing: error ?? StatsServiceError.unknown) - } - } - } - } - - func getDetails(forPostID postID: Int) async throws -> StatsPostDetails { - try await withCheckedThrowingContinuation { continuation in - getDetails(forPostID: postID) { (details: StatsPostDetails?, error: Error?) in - if let details { - continuation.resume(returning: details) - } else { - continuation.resume(throwing: error ?? StatsServiceError.unknown) - } - } - } - } - - func getInsight(limit: Int = 10) async throws -> StatsLastPostInsight { - try await withCheckedThrowingContinuation { continuation in - getInsight(limit: limit) { (insight: StatsLastPostInsight?, error: Error?) in - if let insight { - continuation.resume(returning: insight) - } else { - continuation.resume(throwing: error ?? StatsServiceError.unknown) - } - } - } - } - - func toggleSpamState(for referrerDomain: String, currentValue: Bool) async throws { - try await withCheckedThrowingContinuation { continuation in - toggleSpamState(for: referrerDomain, currentValue: currentValue, success: { - continuation.resume() - }, failure: { error in - continuation.resume(throwing: error) - }) - } - } - - func getEmailSummaryData( - quantity: Int, - sortField: StatsEmailsSummaryData.SortField = .opens, - sortOrder: StatsEmailsSummaryData.SortOrder = .descending - ) async throws -> StatsEmailsSummaryData { - try await withCheckedThrowingContinuation { continuation in - getData(quantity: quantity, sortField: sortField, sortOrder: sortOrder) { result in - switch result { - case .success(let data): - continuation.resume(returning: data) - case .failure(let error): - continuation.resume(throwing: error) - } - } - } - } - - func getEmailOpens(for postID: Int) async throws -> StatsEmailOpensData { - try await withCheckedThrowingContinuation { continuation in - getEmailOpens(for: postID) { (data, error) in - if let data { - continuation.resume(returning: data) - } else { - continuation.resume(throwing: error ?? StatsServiceError.unknown) - } - } - } - } -} diff --git a/Modules/Sources/JetpackStats/Services/StatsServiceProtocol.swift b/Modules/Sources/JetpackStats/Services/StatsServiceProtocol.swift index 94eca4d9d992..5cebb92f0d32 100644 --- a/Modules/Sources/JetpackStats/Services/StatsServiceProtocol.swift +++ b/Modules/Sources/JetpackStats/Services/StatsServiceProtocol.swift @@ -8,6 +8,8 @@ protocol StatsServiceProtocol: AnyObject, Sendable { func getSupportedMetrics(for item: TopListItemType) -> [SiteMetric] func getSiteStats(interval: DateInterval, granularity: DateRangeGranularity) async throws -> SiteMetricsResponse + func getWordAdsStats(date: Date, granularity: DateRangeGranularity) async throws -> WordAdsMetricsResponse + func getWordAdsEarnings() async throws -> WordPressKit.StatsWordAdsEarningsResponse func getTopListData(_ item: TopListItemType, metric: SiteMetric, interval: DateInterval, granularity: DateRangeGranularity, limit: Int?, locationLevel: LocationLevel?) async throws -> TopListResponse func getRealtimeTopListData(_ item: TopListItemType) async throws -> TopListResponse func getPostDetails(for postID: Int) async throws -> StatsPostDetails diff --git a/Modules/Sources/JetpackStats/Strings.swift b/Modules/Sources/JetpackStats/Strings.swift index b20c0796eec5..1ad52e82b6b7 100644 --- a/Modules/Sources/JetpackStats/Strings.swift +++ b/Modules/Sources/JetpackStats/Strings.swift @@ -53,6 +53,28 @@ enum Strings { static let downloads = AppLocalizedString("jetpackStats.siteMetrics.downloads", value: "Downloads", comment: "Download count") } + enum WordAdsMetrics { + static let adsServed = AppLocalizedString("jetpackStats.wordAdsMetrics.adsServed", value: "Ads Served", comment: "Number of ads served") + static let averageCPM = AppLocalizedString("jetpackStats.wordAdsMetrics.averageCPM", value: "Average CPM", comment: "Average cost per mille (thousand impressions)") + static let revenue = AppLocalizedString("jetpackStats.wordAdsMetrics.revenue", value: "Revenue", comment: "Revenue from ads") + } + + enum WordAds { + static let totalEarnings = AppLocalizedString("jetpackStats.wordAds.totalEarnings", value: "Total Earnings", comment: "Title for WordAds total earnings card") + static let earnings = AppLocalizedString("jetpackStats.wordAds.earnings", value: "Earnings", comment: "Total earnings from WordAds") + static let paid = AppLocalizedString("jetpackStats.wordAds.paid", value: "Paid", comment: "Amount paid out from WordAds earnings") + static let outstanding = AppLocalizedString("jetpackStats.wordAds.outstanding", value: "Outstanding", comment: "Outstanding amount owed from WordAds") + static let learnMore = AppLocalizedString("jetpackStats.wordAds.learnMore", value: "Learn More", comment: "Button to learn more about WordAds earnings") + static let paymentsHistory = AppLocalizedString("jetpackStats.wordAds.paymentsHistory", value: "Payments History", comment: "Title for payment history card and screen") + static let noPaymentsYet = AppLocalizedString("jetpackStats.wordAds.noPaymentsYet", value: "No payments yet", comment: "Message shown when there are no payment records") + static func adsServed(_ count: String) -> String { + String.localizedStringWithFormat( + AppLocalizedString("jetpackStats.wordAds.adsServed.count", value: "%@ ads served", comment: "Number of ads served. %@ is the ads count."), + count + ) + } + } + enum SiteDataTypes { static let postsAndPages = AppLocalizedString("jetpackStats.siteDataTypes.postsAndPages", value: "Posts & Pages", comment: "Posts and pages data type") static let archive = AppLocalizedString("jetpackStats.siteDataTypes.archive", value: "Archive", comment: "Archive data type") diff --git a/Modules/Sources/JetpackStats/Utilities/DateRangeGranularity.swift b/Modules/Sources/JetpackStats/Utilities/DateRangeGranularity.swift index 1588f000b53e..246836e67285 100644 --- a/Modules/Sources/JetpackStats/Utilities/DateRangeGranularity.swift +++ b/Modules/Sources/JetpackStats/Utilities/DateRangeGranularity.swift @@ -73,4 +73,16 @@ extension DateRangeGranularity { case .year: .year } } + + /// Preferred quantity of data points to fetch for this granularity. + /// Used by legacy APIs that accept a date and quantity instead of date periods. + var preferredQuantity: Int { + switch self { + case .hour: 24 + case .day: 14 + case .week: 12 + case .month: 12 + case .year: 6 + } + } } diff --git a/Modules/Sources/JetpackStats/Utilities/Formatters/StatsDateRangeFormatter.swift b/Modules/Sources/JetpackStats/Utilities/Formatters/StatsDateRangeFormatter.swift index 739456b9e398..09435de9411f 100644 --- a/Modules/Sources/JetpackStats/Utilities/Formatters/StatsDateRangeFormatter.swift +++ b/Modules/Sources/JetpackStats/Utilities/Formatters/StatsDateRangeFormatter.swift @@ -31,10 +31,16 @@ struct StatsDateRangeFormatter { private let timeZone: TimeZone private let dateFormatter = DateFormatter() private let dateIntervalFormatter = DateIntervalFormatter() + private let now: @Sendable () -> Date - init(locale: Locale = .current, timeZone: TimeZone = .current) { + init( + locale: Locale = .current, + timeZone: TimeZone = .current, + now: @Sendable @escaping () -> Date = { Date() } + ) { self.locale = locale self.timeZone = timeZone + self.now = now dateFormatter.locale = locale dateFormatter.timeZone = timeZone @@ -54,13 +60,14 @@ struct StatsDateRangeFormatter { return string(from: dateRange.dateInterval) } - func string(from interval: DateInterval, now: Date = Date()) -> String { + func string(from interval: DateInterval, now: Date? = nil) -> String { var calendar = Calendar.current calendar.timeZone = timeZone let startDate = interval.start let endDate = interval.end - let currentYear = calendar.component(.year, from: now) + let currentDate = now ?? self.now() + let currentYear = calendar.component(.year, from: currentDate) // Check if it's an entire year if let yearInterval = calendar.dateInterval(of: .year, for: startDate), diff --git a/Modules/Sources/JetpackStats/Utilities/Formatters/StatsValueFormatter.swift b/Modules/Sources/JetpackStats/Utilities/Formatters/StatsValueFormatter.swift index d7112b59f635..5e65c4228d4a 100644 --- a/Modules/Sources/JetpackStats/Utilities/Formatters/StatsValueFormatter.swift +++ b/Modules/Sources/JetpackStats/Utilities/Formatters/StatsValueFormatter.swift @@ -1,5 +1,10 @@ import Foundation +/// Protocol for value formatters that can format metric values. +protocol ValueFormatterProtocol { + func format(value: Int, context: StatsValueFormatter.Context) -> String +} + /// Formats site metric values for display based on the metric type and context. /// /// Example usage: @@ -12,7 +17,7 @@ import Foundation /// viewsFormatter.format(value: 15789) // "15,789" /// viewsFormatter.format(value: 15789, context: .compact) // "16K" /// ``` -struct StatsValueFormatter { +struct StatsValueFormatter: ValueFormatterProtocol { enum Context { case regular case compact @@ -84,3 +89,32 @@ struct StatsValueFormatter { return Double(current - previous) / Double(previous) } } + +/// Formats WordAds metric values for display based on the metric type and context. +struct WordAdsValueFormatter: ValueFormatterProtocol { + let metric: WordAdsMetric + + init(metric: WordAdsMetric) { + self.metric = metric + } + + func format(value: Int, context: StatsValueFormatter.Context = .regular) -> String { + switch metric.id { + case "revenue": + let dollars = Double(value) / 100.0 + return dollars.formatted(.currency(code: "USD")) + case "cpm": + let cpm = Double(value) / 100.0 + return String(format: "$%.2f", cpm) + case "impressions": + return StatsValueFormatter.formatNumber(value, onlyLarge: context == .regular) + default: + return StatsValueFormatter.formatNumber(value, onlyLarge: context == .regular) + } + } + + func percentageChange(current: Int, previous: Int) -> Double { + guard previous > 0 else { return 0 } + return Double(current - previous) / Double(previous) + } +} diff --git a/Modules/Sources/JetpackStats/Utilities/StatsDateRange.swift b/Modules/Sources/JetpackStats/Utilities/StatsDateRange.swift index 3353ae12d403..287f465e8cfb 100644 --- a/Modules/Sources/JetpackStats/Utilities/StatsDateRange.swift +++ b/Modules/Sources/JetpackStats/Utilities/StatsDateRange.swift @@ -66,7 +66,7 @@ struct StatsDateRange: Equatable, Sendable { // MARK: - Navigation /// Navigates to the specified direction (previous or next period). - func navigate(_ direction: Calendar.NavigationDirection) -> StatsDateRange { + func navigate(_ direction: NavigationDirection) -> StatsDateRange { // Use the component if available, otherwise determine it from the interval let newInterval = calendar.navigate(dateInterval, direction: direction, component: component) // When navigating, we lose the preset since it's no longer a standard preset @@ -74,21 +74,22 @@ struct StatsDateRange: Equatable, Sendable { } /// Returns true if can navigate in the specified direction. - func canNavigate(in direction: Calendar.NavigationDirection) -> Bool { - calendar.canNavigate(dateInterval, direction: direction) + func canNavigate(in direction: NavigationDirection, now: Date = .now) -> Bool { + calendar.canNavigate(dateInterval, direction: direction, now: now) } /// Generates a list of available adjacent periods in the specified direction. /// - Parameters: /// - direction: The navigation direction (previous or next) /// - maxCount: Maximum number of periods to generate (default: 10) + /// - now: The reference date for determining navigation bounds (default: .now) /// - Returns: Array of AdjacentPeriod structs - func availableAdjacentPeriods(in direction: Calendar.NavigationDirection, maxCount: Int = 10) -> [AdjacentPeriod] { + func availableAdjacentPeriods(in direction: NavigationDirection, maxCount: Int = 10, now: Date = .now) -> [AdjacentPeriod] { var periods: [AdjacentPeriod] = [] var currentRange = self - let formatter = StatsDateRangeFormatter(timeZone: calendar.timeZone) + let formatter = StatsDateRangeFormatter(timeZone: calendar.timeZone, now: { now }) for _ in 0.. Bool { + lhs.currentValue == rhs.currentValue && + lhs.previousValue == rhs.previousValue && + lhs.context == rhs.context && + String(describing: type(of: lhs.metric)) == String(describing: type(of: rhs.metric)) + } + /// The sign prefix for the change value. var sign: String { currentValue >= previousValue ? "+" : "-" @@ -75,7 +82,7 @@ struct TrendViewModel: Hashable { } private func formattedValue(_ value: Int) -> String { - StatsValueFormatter(metric: metric) + metric.makeValueFormatter() .format(value: value, context: context) } diff --git a/Modules/Sources/JetpackStats/Views/BadgeTrendIndicator.swift b/Modules/Sources/JetpackStats/Views/BadgeTrendIndicator.swift index 02d07e271708..0a219ae27d48 100644 --- a/Modules/Sources/JetpackStats/Views/BadgeTrendIndicator.swift +++ b/Modules/Sources/JetpackStats/Views/BadgeTrendIndicator.swift @@ -28,19 +28,19 @@ struct BadgeTrendIndicator: View { VStack(spacing: 20) { Text("Examples").font(.headline) // 15% increase in views - positive sentiment - BadgeTrendIndicator(trend: TrendViewModel(currentValue: 115, previousValue: 100, metric: .views)) + BadgeTrendIndicator(trend: TrendViewModel(currentValue: 115, previousValue: 100, metric: SiteMetric.views)) // 15% decrease in views - negative sentiment - BadgeTrendIndicator(trend: TrendViewModel(currentValue: 85, previousValue: 100, metric: .views)) + BadgeTrendIndicator(trend: TrendViewModel(currentValue: 85, previousValue: 100, metric: SiteMetric.views)) // 0.1% increase in views - negative sentiment - BadgeTrendIndicator(trend: TrendViewModel(currentValue: 1001, previousValue: 1000, metric: .views)) + BadgeTrendIndicator(trend: TrendViewModel(currentValue: 1001, previousValue: 1000, metric: SiteMetric.views)) Text("Edge Cases").font(.headline).padding(.top) // No change - BadgeTrendIndicator(trend: TrendViewModel(currentValue: 100, previousValue: 100, metric: .views)) + BadgeTrendIndicator(trend: TrendViewModel(currentValue: 100, previousValue: 100, metric: SiteMetric.views)) // Division by zero (from 0 to 100) - BadgeTrendIndicator(trend: TrendViewModel(currentValue: 100, previousValue: 0, metric: .views)) + BadgeTrendIndicator(trend: TrendViewModel(currentValue: 100, previousValue: 0, metric: SiteMetric.views)) // Large change - BadgeTrendIndicator(trend: TrendViewModel(currentValue: 400, previousValue: 100, metric: .views)) + BadgeTrendIndicator(trend: TrendViewModel(currentValue: 400, previousValue: 100, metric: SiteMetric.views)) } .padding() } diff --git a/Modules/Sources/JetpackStats/Views/ChartValuesSummaryView.swift b/Modules/Sources/JetpackStats/Views/ChartValuesSummaryView.swift index 81c253af18bd..6c33800ca65c 100644 --- a/Modules/Sources/JetpackStats/Views/ChartValuesSummaryView.swift +++ b/Modules/Sources/JetpackStats/Views/ChartValuesSummaryView.swift @@ -50,11 +50,11 @@ struct ChartValuesSummaryView: View { #Preview { VStack(spacing: 20) { ForEach(ChartValuesSummaryView.SummaryStyle.allCases, id: \.self) { style in - ChartValuesSummaryView(trend: .init(currentValue: 1000, previousValue: 500, metric: .views), style: style) - ChartValuesSummaryView(trend: .init(currentValue: 500, previousValue: 1000, metric: .views), style: style) - ChartValuesSummaryView(trend: .init(currentValue: 100, previousValue: 100, metric: .views), style: style) - ChartValuesSummaryView(trend: .init(currentValue: 56, previousValue: 60, metric: .bounceRate), style: style) - ChartValuesSummaryView(trend: .init(currentValue: 42, previousValue: 0, metric: .views), style: style) + ChartValuesSummaryView(trend: .init(currentValue: 1000, previousValue: 500, metric: SiteMetric.views), style: style) + ChartValuesSummaryView(trend: .init(currentValue: 500, previousValue: 1000, metric: SiteMetric.views), style: style) + ChartValuesSummaryView(trend: .init(currentValue: 100, previousValue: 100, metric: SiteMetric.views), style: style) + ChartValuesSummaryView(trend: .init(currentValue: 56, previousValue: 60, metric: SiteMetric.bounceRate), style: style) + ChartValuesSummaryView(trend: .init(currentValue: 42, previousValue: 0, metric: SiteMetric.views), style: style) Divider() } } diff --git a/Modules/Sources/JetpackStats/Views/CountryMap/CountryTooltip.swift b/Modules/Sources/JetpackStats/Views/CountryMap/CountryTooltip.swift index c5b5e52285b7..1e2a58cfbeca 100644 --- a/Modules/Sources/JetpackStats/Views/CountryMap/CountryTooltip.swift +++ b/Modules/Sources/JetpackStats/Views/CountryMap/CountryTooltip.swift @@ -39,7 +39,7 @@ struct CountryTooltip: View { return TrendViewModel( currentValue: currentViews, previousValue: previousViews, - metric: .views + metric: SiteMetric.views ) } diff --git a/Modules/Sources/JetpackStats/Views/LegacyFloatingDateControl.swift b/Modules/Sources/JetpackStats/Views/LegacyFloatingDateControl.swift index 0814604723d4..2a48a4a0d655 100644 --- a/Modules/Sources/JetpackStats/Views/LegacyFloatingDateControl.swift +++ b/Modules/Sources/JetpackStats/Views/LegacyFloatingDateControl.swift @@ -91,7 +91,7 @@ struct LegacyFloatingDateControl: View { .floatingStyle() } - private func makeNavigationButton(direction: Calendar.NavigationDirection) -> some View { + private func makeNavigationButton(direction: NavigationDirection) -> some View { let isDisabled = !dateRange.canNavigate(in: direction) return Menu { ForEach(dateRange.availableAdjacentPeriods(in: direction)) { period in diff --git a/Modules/Sources/JetpackStats/Views/MetricsOverviewTabView.swift b/Modules/Sources/JetpackStats/Views/MetricsOverviewTabView.swift index d76219ca7c14..e0cabb027dbd 100644 --- a/Modules/Sources/JetpackStats/Views/MetricsOverviewTabView.swift +++ b/Modules/Sources/JetpackStats/Views/MetricsOverviewTabView.swift @@ -4,17 +4,18 @@ import WordPressUI /// A horizontal scrollable tab view displaying metric summaries with values and trends. /// /// Each tab shows a metric's current value, percentage change, and visual selection indicator. -struct MetricsOverviewTabView: View { +struct MetricsOverviewTabView: View { /// Data for a single metric tab struct MetricData { - let metric: SiteMetric + let metric: Metric let value: Int? let previousValue: Int? } let data: [MetricData] - @Binding var selectedMetric: SiteMetric - var onMetricSelected: ((SiteMetric) -> Void)? + @Binding var selectedMetric: Metric + var onMetricSelected: ((Metric) -> Void)? + var showTrend: Bool = true @ScaledMetric(relativeTo: .title) private var minTabWidth: CGFloat = 100 @@ -35,12 +36,12 @@ struct MetricsOverviewTabView: View { } private func makeItemView(for item: MetricData, onTap: @escaping () -> Void) -> some View { - MetricItemView(data: item, isSelected: selectedMetric == item.metric, onTap: onTap) + MetricItemView(data: item, isSelected: selectedMetric == item.metric, showTrend: showTrend, onTap: onTap) .frame(minWidth: minTabWidth) .id(item.metric) } - private func selectDataType(_ type: SiteMetric, proxy: ScrollViewProxy) { + private func selectDataType(_ type: Metric, proxy: ScrollViewProxy) { withAnimation(.spring) { selectedMetric = type proxy.scrollTo(type, anchor: .center) @@ -50,13 +51,14 @@ struct MetricsOverviewTabView: View { } } -private struct MetricItemView: View { - let data: MetricsOverviewTabView.MetricData +private struct MetricItemView: View { + let data: MetricsOverviewTabView.MetricData let isSelected: Bool + let showTrend: Bool let onTap: () -> Void - private var valueFormatter: StatsValueFormatter { - StatsValueFormatter(metric: data.metric) + private var valueFormatter: any ValueFormatterProtocol { + data.metric.makeValueFormatter() } private var formattedValue: String { @@ -97,9 +99,11 @@ private struct MetricItemView: View { private var headerView: some View { HStack(spacing: 2) { - Image(systemName: data.metric.systemImage) - .font(.caption2.weight(.medium)) - .scaleEffect(x: 0.9, y: 0.9) + if showTrend { + Image(systemName: data.metric.systemImage) + .font(.caption2.weight(.medium)) + .scaleEffect(x: 0.9, y: 0.9) + } Text(data.metric.localizedTitle.uppercased()) .font(.caption.weight(.medium)) } @@ -117,15 +121,17 @@ private struct MetricItemView: View { .lineLimit(1) .animation(.spring, value: formattedValue) - if let trend { - BadgeTrendIndicator(trend: trend) - } else { - // Placeholder for loading state - BadgeTrendIndicator( - trend: TrendViewModel(currentValue: 125, previousValue: 100, metric: data.metric) - ) - .grayscale(1) - .redacted(reason: .placeholder) + if showTrend { + if let trend { + BadgeTrendIndicator(trend: trend) + } else { + // Placeholder for loading state + BadgeTrendIndicator( + trend: TrendViewModel(currentValue: 125, previousValue: 100, metric: data.metric) + ) + .grayscale(1) + .redacted(reason: .placeholder) + } } } .padding(.trailing, 8) @@ -147,7 +153,7 @@ private struct MetricItemView: View { #if DEBUG #Preview { - let mockData: [MetricsOverviewTabView.MetricData] = [ + let mockData: [MetricsOverviewTabView.MetricData] = [ .init(metric: .views, value: 128400, previousValue: 142600), .init(metric: .visitors, value: 49800, previousValue: 54200), .init(metric: .likes, value: nil, previousValue: nil), diff --git a/Modules/Sources/JetpackStats/Views/StatsDateRangeButtons.swift b/Modules/Sources/JetpackStats/Views/StatsDateRangeButtons.swift index ded47f4805b8..41bf7186d5b9 100644 --- a/Modules/Sources/JetpackStats/Views/StatsDateRangeButtons.swift +++ b/Modules/Sources/JetpackStats/Views/StatsDateRangeButtons.swift @@ -51,7 +51,7 @@ struct StatsDatePickerToolbarItem: View { struct StatsNavigationButton: View { @Binding var dateRange: StatsDateRange - let direction: Calendar.NavigationDirection + let direction: NavigationDirection @Environment(\.context) var context diff --git a/Modules/Sources/JetpackStats/Views/WordAds/WordAdsPaymentHistoryRowView.swift b/Modules/Sources/JetpackStats/Views/WordAds/WordAdsPaymentHistoryRowView.swift new file mode 100644 index 000000000000..dd4b2d4d8dcc --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/WordAds/WordAdsPaymentHistoryRowView.swift @@ -0,0 +1,71 @@ +import SwiftUI +@preconcurrency import WordPressKit + +struct WordAdsPaymentHistoryRowView: View { + let viewModel: WordAdsPaymentHistoryRowViewModel + + var body: some View { + HStack(alignment: .center, spacing: 0) { + // Period and Status + VStack(alignment: .leading, spacing: 2) { + Text(viewModel.formattedPeriod) + .font(.subheadline.weight(.medium)) + .foregroundColor(.primary) + + statusBadge + } + + Spacer(minLength: 6) + + // Metrics + VStack(alignment: .trailing, spacing: 2) { + Text(viewModel.formattedAmount) + .font(.subheadline.weight(.medium)) + .foregroundColor(.primary) + + Text(viewModel.formattedAdsServed) + .font(.caption) + .foregroundColor(.secondary) + } + } + } + + private var statusBadge: some View { + HStack(spacing: 4) { + Text(viewModel.statusText) + .font(.caption2.weight(.medium)) + } + .foregroundColor(.secondary) + } +} + +#Preview { + List { + WordAdsPaymentHistoryRowView( + viewModel: WordAdsPaymentHistoryRowViewModel( + earning: StatsWordAdsEarningsResponse.MonthlyEarning( + period: StatsWordAdsEarningsResponse.Period(year: 2025, month: 12), + data: StatsWordAdsEarningsResponse.MonthlyEarningData( + amount: 15.25, + status: .outstanding, + pageviews: "3420" + ) + ) + ) + ) + + WordAdsPaymentHistoryRowView( + viewModel: WordAdsPaymentHistoryRowViewModel( + earning: StatsWordAdsEarningsResponse.MonthlyEarning( + period: StatsWordAdsEarningsResponse.Period(year: 2025, month: 8), + data: StatsWordAdsEarningsResponse.MonthlyEarningData( + amount: 5.50, + status: .paid, + pageviews: "1200" + ) + ) + ) + ) + } + .listStyle(.plain) +} diff --git a/Modules/Sources/JetpackStats/Views/WordAds/WordAdsPaymentHistoryRowViewModel.swift b/Modules/Sources/JetpackStats/Views/WordAds/WordAdsPaymentHistoryRowViewModel.swift new file mode 100644 index 000000000000..f442599b87b7 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/WordAds/WordAdsPaymentHistoryRowViewModel.swift @@ -0,0 +1,31 @@ +import Foundation +@preconcurrency import WordPressKit + +struct WordAdsPaymentHistoryRowViewModel { + let earning: StatsWordAdsEarningsResponse.MonthlyEarning + + var formattedPeriod: String { + var components = DateComponents() + components.year = earning.period.year + components.month = earning.period.month + components.day = 1 + + guard let date = Calendar.current.date(from: components) else { + return earning.period.string + } + + return date.formatted(.dateTime.month(.abbreviated).year()) + } + + var formattedAmount: String { + earning.amount.formatted(.currency(code: "USD")) + } + + var formattedAdsServed: String { + Strings.WordAds.adsServed(earning.pageviews) + } + + var statusText: String { + earning.status == .paid ? Strings.WordAds.paid : Strings.WordAds.outstanding + } +} diff --git a/Modules/Sources/WordPressKit/StatsServiceRemoteV2.swift b/Modules/Sources/WordPressKit/StatsServiceRemoteV2.swift index 067c8fdc7739..d6bc6432b09f 100644 --- a/Modules/Sources/WordPressKit/StatsServiceRemoteV2.swift +++ b/Modules/Sources/WordPressKit/StatsServiceRemoteV2.swift @@ -395,6 +395,16 @@ public extension StatsServiceRemoteV2 { } } +// MARK: - WordAds Earnings + +public extension StatsServiceRemoteV2 { + func getWordAdsEarnings() async throws -> StatsWordAdsEarningsResponse { + let path = self.path(forEndpoint: "sites/\(siteID)/wordads/earnings", withVersion: ._1_1) + let result = await wordPressComRestApi.perform(.get, URLString: path, parameters: [:], type: StatsWordAdsEarningsResponse.self) + return try result.get().body + } +} + // This serves both as a way to get the query properties in a "nice" way, // but also as a way to narrow down the generic type in `getInsight(completion:)` method. public protocol StatsInsightData { diff --git a/Modules/Sources/WordPressKit/StatsWordAdsEarningsResponse.swift b/Modules/Sources/WordPressKit/StatsWordAdsEarningsResponse.swift new file mode 100644 index 000000000000..89575f8e615f --- /dev/null +++ b/Modules/Sources/WordPressKit/StatsWordAdsEarningsResponse.swift @@ -0,0 +1,158 @@ +import Foundation + +public struct StatsWordAdsEarningsResponse: Decodable { + public let totalEarnings: Decimal + public let totalAmountOwed: Decimal + public let wordAdsEarnings: [MonthlyEarning] + + private enum CodingKeys: String, CodingKey { + case earnings + } + + private struct EarningsContainer: Decodable { + let totalEarnings: FlexibleDecimal + let totalAmountOwed: FlexibleDecimal + let wordads: [String: MonthlyEarningData] + + private enum CodingKeys: String, CodingKey { + case totalEarnings = "total_earnings" + case totalAmountOwed = "total_amount_owed" + case wordads + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let earningsContainer = try container.decode(EarningsContainer.self, forKey: .earnings) + totalEarnings = earningsContainer.totalEarnings.value + totalAmountOwed = earningsContainer.totalAmountOwed.value + + // Convert dictionary to sorted array + var earnings = earningsContainer.wordads.compactMap { (period, data) -> MonthlyEarning? in + guard let parsedPeriod = Period(string: period) else { return nil } + return MonthlyEarning(period: parsedPeriod, data: data) + } + earnings.sort { $0.period > $1.period } + wordAdsEarnings = earnings + } + + public struct MonthlyEarning { + public let period: Period + public let amount: Decimal + public let status: PaymentStatus + public let pageviews: String + + public init(period: Period, data: MonthlyEarningData) { + self.period = period + self.amount = data.amount + self.status = data.status + self.pageviews = data.pageviews + } + } + + public struct MonthlyEarningData: Decodable { + private let _amount: FlexibleDecimal + private let _status: FlexiblePaymentStatus + + public let pageviews: String + + public var amount: Decimal { _amount.value } + public var status: PaymentStatus { _status.value } + + public init(amount: Decimal, status: PaymentStatus, pageviews: String) { + self._amount = FlexibleDecimal(value: amount) + self._status = FlexiblePaymentStatus(value: status) + self.pageviews = pageviews + } + + private enum CodingKeys: String, CodingKey { + case _amount = "amount" + case _status = "status" + case pageviews + } + } + + public enum PaymentStatus: Equatable { + case paid + case outstanding + } + + public struct Period: Equatable, Comparable, Hashable { + public let year: Int + public let month: Int + + public init(year: Int, month: Int) { + self.year = year + self.month = month + } + + init?(string: String) { + let components = string.split(separator: "-") + guard components.count == 2, + let year = Int(components[0]), + let month = Int(components[1]), + (1...12).contains(month) else { + return nil + } + self.year = year + self.month = month + } + + public var string: String { + String(format: "%04d-%02d", year, month) + } + + public static func < (lhs: Period, rhs: Period) -> Bool { + (lhs.year, lhs.month) < (rhs.year, rhs.month) + } + } +} + +// MARK: - Decoding Helpers + +private struct FlexibleDecimal: Decodable { + let value: Decimal + + init(value: Decimal) { + self.value = value + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let stringValue = try? container.decode(String.self), let decimal = Decimal(string: stringValue) { + value = decimal + } else if let doubleValue = try? container.decode(Double.self) { + value = Decimal(doubleValue) + } else if let intValue = try? container.decode(Int.self) { + value = Decimal(intValue) + } else { + throw DecodingError.typeMismatch(Decimal.self, DecodingError.Context( + codingPath: container.codingPath, + debugDescription: "Expected string or number" + )) + } + } +} + +private struct FlexiblePaymentStatus: Decodable { + let value: StatsWordAdsEarningsResponse.PaymentStatus + + init(value: StatsWordAdsEarningsResponse.PaymentStatus) { + self.value = value + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let stringValue = try? container.decode(String.self) { + value = stringValue == "1" ? .paid : .outstanding + } else if let intValue = try? container.decode(Int.self) { + value = intValue == 1 ? .paid : .outstanding + } else { + throw DecodingError.typeMismatch(StatsWordAdsEarningsResponse.PaymentStatus.self, DecodingError.Context( + codingPath: container.codingPath, + debugDescription: "Expected string or int" + )) + } + } +} diff --git a/Modules/Sources/WordPressKit/StatsWordAdsResponse.swift b/Modules/Sources/WordPressKit/StatsWordAdsResponse.swift new file mode 100644 index 000000000000..7c8b4439f4f0 --- /dev/null +++ b/Modules/Sources/WordPressKit/StatsWordAdsResponse.swift @@ -0,0 +1,114 @@ +import Foundation + +public struct StatsWordAdsResponse { + public var period: StatsPeriodUnit + public var periodEndDate: Date + public let data: [PeriodData] + + public enum Metric: String, CaseIterable { + case impressions + case revenue + case cpm + } + + public struct PeriodData { + /// Period date in the site timezone. + public var date: Date + public var impressions: Int? + public var revenue: Double? + public var cpm: Double? + + public subscript(metric: Metric) -> Double? { + switch metric { + case .impressions: impressions.map(Double.init) + case .revenue: revenue + case .cpm: cpm + } + } + } +} + +extension StatsWordAdsResponse: StatsTimeIntervalData { + public static var pathComponent: String { + "wordads/stats" + } + + public static func queryProperties(with date: Date, period: StatsPeriodUnit, maxCount: Int) -> [String: String] { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd" + dateFormatter.timeZone = TimeZone.current + let dateString = dateFormatter.string(from: date) + + return [ + "unit": period.stringValue, + "date": dateString, + "quantity": String(maxCount) + ] + } + + public init?(date: Date, period: StatsPeriodUnit, jsonDictionary: [String: AnyObject]) { + self.init(date: date, period: period, unit: nil, jsonDictionary: jsonDictionary) + } + + public init?(date: Date, period: StatsPeriodUnit, unit: StatsPeriodUnit?, jsonDictionary: [String: AnyObject]) { + guard let fields = jsonDictionary["fields"] as? [String], + let data = jsonDictionary["data"] as? [[Any]] else { + return nil + } + + guard let periodIndex = fields.firstIndex(of: "period") else { + return nil + } + + self.period = period + self.periodEndDate = date + + let indices = ( + impressions: fields.firstIndex(of: Metric.impressions.rawValue), + revenue: fields.firstIndex(of: Metric.revenue.rawValue), + cpm: fields.firstIndex(of: Metric.cpm.rawValue) + ) + + let dateFormatter = makeDateFormatter(for: period) + + self.data = data.compactMap { data in + guard let date = dateFormatter.date(from: data[periodIndex] as? String ?? "") else { + return nil + } + + func getIntValue(at index: Int?) -> Int? { + guard let index else { return nil } + return data[index] as? Int + } + + func getDoubleValue(at index: Int?) -> Double? { + guard let index else { return nil } + if let doubleValue = data[index] as? Double { + return doubleValue + } else if let intValue = data[index] as? Int { + return Double(intValue) + } + return nil + } + + return PeriodData( + date: date, + impressions: getIntValue(at: indices.impressions), + revenue: getDoubleValue(at: indices.revenue), + cpm: getDoubleValue(at: indices.cpm) + ) + } + } +} + +private func makeDateFormatter(for unit: StatsPeriodUnit) -> DateFormatter { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = { + switch unit { + case .hour: "yyyy-MM-dd HH:mm:ss" + case .day, .week, .month, .year: "yyyy-MM-dd" + } + }() + return formatter +} diff --git a/Modules/Sources/WordPressUI/Views/AdaptiveTabBarController.swift b/Modules/Sources/WordPressUI/Views/AdaptiveTabBarController.swift index ce7b06da9d06..3f0750f2749a 100644 --- a/Modules/Sources/WordPressUI/Views/AdaptiveTabBarController.swift +++ b/Modules/Sources/WordPressUI/Views/AdaptiveTabBarController.swift @@ -86,7 +86,13 @@ public final class AdaptiveTabBarController { if viewController.traitCollection.horizontalSizeClass == .regular { filterBarContainer.removeFromSuperview() - (navigationItem ?? viewController.navigationItem).leftBarButtonItem = UIBarButtonItem(customView: segmentedControl) + (navigationItem ?? viewController.navigationItem).leftBarButtonItem = { + let button = UIBarButtonItem(customView: segmentedControl) + if #available(iOS 26.0, *) { + button.hidesSharedBackground = true + } + return button + }() viewController.additionalSafeAreaInsets = .zero } else { viewController.navigationItem.titleView = nil diff --git a/Modules/Tests/JetpackStatsTests/DateRangeGranularityTests.swift b/Modules/Tests/JetpackStatsTests/DateRangeGranularityTests.swift index d43fe2648026..da6618f29541 100644 --- a/Modules/Tests/JetpackStatsTests/DateRangeGranularityTests.swift +++ b/Modules/Tests/JetpackStatsTests/DateRangeGranularityTests.swift @@ -58,7 +58,7 @@ struct DateRangeGranularityTests { start: Date("2024-01-01T00:00:00-03:00"), end: Date("2024-03-31T00:00:00-03:00") ) - #expect(ninetyDays.preferredGranularity == .day) + #expect(ninetyDays.preferredGranularity == .week) } @Test("Determined period for 91+ days returns month granularity") @@ -99,14 +99,14 @@ struct DateRangeGranularityTests { start: Date("2024-01-01T00:00:00-03:00"), end: Date("2026-02-02T00:00:00-03:00") ) - #expect(twentyFiveMonths.preferredGranularity == .month) + #expect(twentyFiveMonths.preferredGranularity == .year) // 3 years let threeYears = DateInterval( start: Date("2024-01-01T00:00:00-03:00"), end: Date("2027-01-02T00:00:00-03:00") ) - #expect(threeYears.preferredGranularity == .month) + #expect(threeYears.preferredGranularity == .year) // 5 years let fiveYears = DateInterval( @@ -144,7 +144,7 @@ struct DateRangeGranularityTests { start: Date("2024-01-01T00:00:00-03:00"), end: Date("2024-03-31T00:00:00-03:00") ) - #expect(ninetyDays.preferredGranularity == .day) + #expect(ninetyDays.preferredGranularity == .week) // 91 days - should be month let ninetyOneDays = DateInterval( diff --git a/Modules/Tests/JetpackStatsTests/MockStatsServiceTests.swift b/Modules/Tests/JetpackStatsTests/MockStatsServiceTests.swift index 745e413489f5..4c3b3cf19d29 100644 --- a/Modules/Tests/JetpackStatsTests/MockStatsServiceTests.swift +++ b/Modules/Tests/JetpackStatsTests/MockStatsServiceTests.swift @@ -19,7 +19,8 @@ struct MockStatsServiceTests { metric: .views, interval: dateInterval, granularity: dateInterval.preferredGranularity, - limit: nil + limit: nil, + locationLevel: nil ) print("elapsed: \((CFAbsoluteTimeGetCurrent() - startTime) * 1000) ms") diff --git a/Modules/Tests/JetpackStatsTests/StatsDataAggregationTests.swift b/Modules/Tests/JetpackStatsTests/StatsDataAggregationTests.swift index eb2c2c6ade4d..c510658d2e48 100644 --- a/Modules/Tests/JetpackStatsTests/StatsDataAggregationTests.swift +++ b/Modules/Tests/JetpackStatsTests/StatsDataAggregationTests.swift @@ -23,7 +23,7 @@ struct StatsDataAggregationTests { DataPoint(date: date4, value: 300) ] - let aggregated = aggregator.aggregate(testData, granularity: .hour, metric: .views) + let aggregated = aggregator.aggregate(testData, granularity: .hour, metric: SiteMetric.views) // Should have 2 hours worth of data #expect(aggregated.count == 2) @@ -49,7 +49,7 @@ struct StatsDataAggregationTests { DataPoint(date: Date("2025-01-16T10:00:00Z"), value: 300) ] - let aggregated = aggregator.aggregate(testData, granularity: .day, metric: .views) + let aggregated = aggregator.aggregate(testData, granularity: .day, metric: SiteMetric.views) #expect(aggregated.count == 2) @@ -70,7 +70,7 @@ struct StatsDataAggregationTests { DataPoint(date: Date("2025-02-10T10:00:00Z"), value: 300) ] - let aggregated = aggregator.aggregate(testData, granularity: .month, metric: .views) + let aggregated = aggregator.aggregate(testData, granularity: .month, metric: SiteMetric.views) #expect(aggregated.count == 2) @@ -91,7 +91,7 @@ struct StatsDataAggregationTests { DataPoint(date: Date("2025-05-10T10:00:00Z"), value: 300) ] - let aggregated = aggregator.aggregate(testData, granularity: .year, metric: .views) + let aggregated = aggregator.aggregate(testData, granularity: .year, metric: SiteMetric.views) // Year granularity aggregates by month #expect(aggregated.count == 1) @@ -204,7 +204,7 @@ struct StatsDataAggregationTests { ] // Test with timeOnSite metric which uses average strategy - let aggregated = aggregator.aggregate(testData, granularity: .day, metric: .timeOnSite) + let aggregated = aggregator.aggregate(testData, granularity: .day, metric: SiteMetric.timeOnSite) #expect(aggregated.count == 2) @@ -249,7 +249,7 @@ struct StatsDataAggregationTests { dataPoints: filteredDataPoints, dateInterval: dateInterval, granularity: .day, - metric: .views + metric: SiteMetric.views ) // Should have 3 days of data @@ -297,7 +297,7 @@ struct StatsDataAggregationTests { dataPoints: filteredDataPoints, dateInterval: dateInterval, granularity: .hour, - metric: .views + metric: SiteMetric.views ) // Should have 3 hours of data @@ -338,7 +338,7 @@ struct StatsDataAggregationTests { dataPoints: filteredDataPoints, dateInterval: dateInterval, granularity: .day, - metric: .timeOnSite + metric: SiteMetric.timeOnSite ) // Values should be averaged per day @@ -373,7 +373,7 @@ struct StatsDataAggregationTests { dataPoints: filteredDataPoints, dateInterval: dateInterval, granularity: .day, - metric: .views + metric: SiteMetric.views ) // Should still have dates but with zero values @@ -409,7 +409,7 @@ struct StatsDataAggregationTests { dataPoints: filteredDataPoints, dateInterval: dateInterval, granularity: .month, - metric: .views + metric: SiteMetric.views ) // Should have 2 months (Jan and Feb) diff --git a/Modules/Tests/JetpackStatsTests/StatsDateFormatterTests.swift b/Modules/Tests/JetpackStatsTests/StatsDateFormatterTests.swift index 9bdadbf6b46c..4eef42e0384c 100644 --- a/Modules/Tests/JetpackStatsTests/StatsDateFormatterTests.swift +++ b/Modules/Tests/JetpackStatsTests/StatsDateFormatterTests.swift @@ -6,7 +6,8 @@ import Foundation struct StatsDateFormatterTests { let formatter = StatsDateFormatter( locale: Locale(identifier: "en_us"), - timeZone: .eastern + timeZone: .eastern, + now: { Date("2025-03-15T14:00:00-03:00") } ) @Test func hourFormattingCompact() { diff --git a/Modules/Tests/JetpackStatsTests/StatsDateRangeFormatterTests.swift b/Modules/Tests/JetpackStatsTests/StatsDateRangeFormatterTests.swift index f940e5455c36..77989299fe2b 100644 --- a/Modules/Tests/JetpackStatsTests/StatsDateRangeFormatterTests.swift +++ b/Modules/Tests/JetpackStatsTests/StatsDateRangeFormatterTests.swift @@ -7,6 +7,11 @@ struct StatsDateRangeFormatterTests { let calendar = Calendar.mock(timeZone: .eastern) let locale = Locale(identifier: "en_US") let now = Date("2025-07-15T10:00:00-03:00") + let formatter = StatsDateRangeFormatter( + locale: Locale(identifier: "en_US"), + timeZone: .eastern, + now: { Date("2025-07-15T10:00:00-03:00") } + ) // MARK: - Date Range Formatting @@ -25,7 +30,6 @@ struct StatsDateRangeFormatterTests { ]) func dateRangeFormatting(startDate: Date, endDate: Date, expected: String) { let interval = DateInterval(start: startDate, end: endDate) - let formatter = StatsDateRangeFormatter(locale: locale, timeZone: calendar.timeZone) #expect(formatter.string(from: interval) == expected) } @@ -39,7 +43,6 @@ struct StatsDateRangeFormatterTests { ]) func entireMonthFormatting(startDate: Date, endDate: Date, expected: String) { let interval = DateInterval(start: startDate, end: endDate) - let formatter = StatsDateRangeFormatter(locale: locale, timeZone: calendar.timeZone) #expect(formatter.string(from: interval) == expected) } @@ -59,7 +62,6 @@ struct StatsDateRangeFormatterTests { ]) func multipleFullMonthsFormatting(startDate: Date, endDate: Date, expected: String) { let interval = DateInterval(start: startDate, end: endDate) - let formatter = StatsDateRangeFormatter(locale: locale, timeZone: calendar.timeZone) #expect(formatter.string(from: interval) == expected) } @@ -75,7 +77,6 @@ struct StatsDateRangeFormatterTests { ]) func entireWeekFormatting(startDate: Date, endDate: Date, expected: String) { let interval = DateInterval(start: startDate, end: endDate) - let formatter = StatsDateRangeFormatter(locale: locale, timeZone: calendar.timeZone) #expect(formatter.string(from: interval) == expected) } @@ -86,7 +87,6 @@ struct StatsDateRangeFormatterTests { ]) func entireYearFormatting(startDate: Date, endDate: Date, expected: String) { let interval = DateInterval(start: startDate, end: endDate) - let formatter = StatsDateRangeFormatter(locale: locale, timeZone: calendar.timeZone) #expect(formatter.string(from: interval) == expected) } @@ -104,7 +104,6 @@ struct StatsDateRangeFormatterTests { ]) func multipleFullYearsFormatting(startDate: Date, endDate: Date, expected: String) { let interval = DateInterval(start: startDate, end: endDate) - let formatter = StatsDateRangeFormatter(locale: locale, timeZone: calendar.timeZone) #expect(formatter.string(from: interval) == expected) } @@ -112,48 +111,45 @@ struct StatsDateRangeFormatterTests { @Test("Same year as current year formatting") func sameYearAsCurrentFormatting() { - let formatter = StatsDateRangeFormatter(locale: locale, timeZone: calendar.timeZone) - let now = Date("2025-07-15T10:00:00-03:00") // Fixed date in 2025 + let testNow = Date("2025-07-15T10:00:00-03:00") // Fixed date in 2025 // Single day in current year - no year shown let singleDay = DateInterval(start: Date("2025-03-15T00:00:00-03:00"), end: Date("2025-03-16T00:00:00-03:00")) - #expect(formatter.string(from: singleDay, now: now) == "Mar 15") + #expect(formatter.string(from: singleDay, now: testNow) == "Mar 15") // Range within current year - no year shown let rangeInYear = DateInterval(start: Date("2025-05-01T00:00:00-03:00"), end: Date("2025-05-08T00:00:00-03:00")) - #expect(formatter.string(from: rangeInYear, now: now) == "May 1 – 7") + #expect(formatter.string(from: rangeInYear, now: testNow) == "May 1 – 7") // Cross month range in current year - no year shown let crossMonth = DateInterval(start: Date("2025-06-28T00:00:00-03:00"), end: Date("2025-07-03T00:00:00-03:00")) - #expect(formatter.string(from: crossMonth, now: now) == "Jun 28 – Jul 2") + #expect(formatter.string(from: crossMonth, now: testNow) == "Jun 28 – Jul 2") } @Test("Previous year formatting") func previousYearFormatting() { - let formatter = StatsDateRangeFormatter(locale: locale, timeZone: calendar.timeZone) - let now = Date("2025-07-15T10:00:00-03:00") // Fixed date in 2025 + let testNow = Date("2025-07-15T10:00:00-03:00") // Fixed date in 2025 // Single day in previous year - year shown let singleDay = DateInterval(start: Date("2024-03-15T00:00:00-03:00"), end: Date("2024-03-16T00:00:00-03:00")) - #expect(formatter.string(from: singleDay, now: now) == "Mar 15, 2024") + #expect(formatter.string(from: singleDay, now: testNow) == "Mar 15, 2024") // Range within previous year - year shown at end let rangeInYear = DateInterval(start: Date("2024-05-01T00:00:00-03:00"), end: Date("2024-05-08T00:00:00-03:00")) - #expect(formatter.string(from: rangeInYear, now: now) == "May 1 – 7, 2024") + #expect(formatter.string(from: rangeInYear, now: testNow) == "May 1 – 7, 2024") // Cross month range in previous year - year shown at end let crossMonth = DateInterval(start: Date("2024-06-28T00:00:00-03:00"), end: Date("2024-07-03T00:00:00-03:00")) - #expect(formatter.string(from: crossMonth, now: now) == "Jun 28 – Jul 2, 2024") + #expect(formatter.string(from: crossMonth, now: testNow) == "Jun 28 – Jul 2, 2024") } @Test("Cross year formatting with current year") func crossYearWithCurrentFormatting() { - let formatter = StatsDateRangeFormatter(locale: locale, timeZone: calendar.timeZone) - let now = Date("2025-07-15T10:00:00-03:00") // Fixed date in 2025 + let testNow = Date("2025-07-15T10:00:00-03:00") // Fixed date in 2025 // Range from previous to current year - both years shown let crossYear = DateInterval(start: Date("2024-12-28T00:00:00-03:00"), end: Date("2025-01-03T00:00:00-03:00")) - #expect(formatter.string(from: crossYear, now: now) == "Dec 28, 2024 – Jan 2, 2025") + #expect(formatter.string(from: crossYear, now: testNow) == "Dec 28, 2024 – Jan 2, 2025") } // MARK: - DateRangePreset Integration Tests @@ -168,28 +164,24 @@ struct StatsDateRangeFormatterTests { ]) func dateRangePresetFormattingCurrentYear(preset: DateIntervalPreset, expected: String) { // Set up a specific date in 2025 - let now = Date("2025-03-15T14:30:00-03:00") - let formatter = StatsDateRangeFormatter(locale: locale, timeZone: calendar.timeZone) + let testNow = Date("2025-03-15T14:30:00-03:00") - let interval = calendar.makeDateInterval(for: preset, now: now) - #expect(formatter.string(from: interval, now: now) == expected) + let interval = calendar.makeDateInterval(for: preset, now: testNow) + #expect(formatter.string(from: interval, now: testNow) == expected) } @Test("DateRangePreset formatting - year boundaries") func dateRangePresetFormattingYearBoundaries() { // Test date near year boundary - January 5, 2025 - let now = Date("2025-01-05T10:00:00-03:00") - let formatter = StatsDateRangeFormatter(locale: locale, timeZone: calendar.timeZone) + let testNow = Date("2025-01-05T10:00:00-03:00") // Last 30 days crosses year boundary - let last30Days = calendar.makeDateInterval(for: .last30Days, now: now) - #expect(formatter.string(from: last30Days, now: now) == "Dec 6, 2024 – Jan 4, 2025") + let last30Days = calendar.makeDateInterval(for: .last30Days, now: testNow) + #expect(formatter.string(from: last30Days, now: testNow) == "Dec 6, 2024 – Jan 4, 2025") } @Test("DateRangePreset formatting - custom ranges") func dateRangePresetFormattingCustomRanges() { - let formatter = StatsDateRangeFormatter(locale: locale, timeZone: calendar.timeZone) - // Custom single day let customDay = DateInterval( start: Date("2025-06-10T00:00:00-03:00"), @@ -223,7 +215,11 @@ struct StatsDateRangeFormatterTests { func differentLocales(localeId: String, startDate: Date, endDate: Date, expected: String) { let locale = Locale(identifier: localeId) let interval = DateInterval(start: startDate, end: endDate) - let formatter = StatsDateRangeFormatter(locale: locale, timeZone: calendar.timeZone) + let formatter = StatsDateRangeFormatter( + locale: locale, + timeZone: calendar.timeZone, + now: { Date("2025-07-15T10:00:00-03:00") } + ) #expect(formatter.string(from: interval) == expected) } } diff --git a/Modules/Tests/JetpackStatsTests/StatsDateRangeTests.swift b/Modules/Tests/JetpackStatsTests/StatsDateRangeTests.swift index 76e32af10c90..90dfaa56968e 100644 --- a/Modules/Tests/JetpackStatsTests/StatsDateRangeTests.swift +++ b/Modules/Tests/JetpackStatsTests/StatsDateRangeTests.swift @@ -75,6 +75,7 @@ struct StatsDateRangeTests { @Test func testAvailableAdjacentPeriods() { // GIVEN + let now = Date("2025-12-31T23:59:59Z") // Fixed date in 2025 for consistent test results let initialRange = StatsDateRange( interval: DateInterval( start: Date("2020-01-01T00:00:00Z"), @@ -85,7 +86,7 @@ struct StatsDateRangeTests { ) // WHEN - Test backward navigation - let backwardPeriods = initialRange.availableAdjacentPeriods(in: .backward, maxCount: 10) + let backwardPeriods = initialRange.availableAdjacentPeriods(in: .backward, maxCount: 10, now: now) // THEN #expect(backwardPeriods.count == 10) @@ -99,7 +100,7 @@ struct StatsDateRangeTests { #expect(backwardPeriods[0].range.dateInterval.end == Date("2020-01-01T00:00:00Z")) // WHEN - Test forward navigation (should be limited by current date) - let forwardPeriods = initialRange.availableAdjacentPeriods(in: .forward, maxCount: 10) + let forwardPeriods = initialRange.availableAdjacentPeriods(in: .forward, maxCount: 10, now: now) // THEN - Should have 5 periods available (2021, 2022, 2023, 2024, 2025) #expect(forwardPeriods.count == 5) diff --git a/Modules/Tests/JetpackStatsTests/TrendViewModelTests.swift b/Modules/Tests/JetpackStatsTests/TrendViewModelTests.swift index 1015f4a41078..bd39f9e964df 100644 --- a/Modules/Tests/JetpackStatsTests/TrendViewModelTests.swift +++ b/Modules/Tests/JetpackStatsTests/TrendViewModelTests.swift @@ -12,7 +12,7 @@ struct TrendViewModelTests { ]) func testSign(current: Int, previous: Int, expectedSign: String) { // GIVEN - let viewModel = TrendViewModel(currentValue: current, previousValue: previous, metric: .views) + let viewModel = TrendViewModel(currentValue: current, previousValue: previous, metric: SiteMetric.views) // WHEN let sign = viewModel.sign @@ -64,7 +64,7 @@ struct TrendViewModelTests { ]) func testPercentageCalculation(current: Int, previous: Int, expected: Decimal) { // GIVEN - let viewModel = TrendViewModel(currentValue: current, previousValue: previous, metric: .views) + let viewModel = TrendViewModel(currentValue: current, previousValue: previous, metric: SiteMetric.views) // WHEN let percentage = viewModel.percentage @@ -79,7 +79,7 @@ struct TrendViewModelTests { ]) func testPercentageCalculationWithZeroDivisor(current: Int, previous: Int) { // GIVEN - let viewModel = TrendViewModel(currentValue: current, previousValue: previous, metric: .views) + let viewModel = TrendViewModel(currentValue: current, previousValue: previous, metric: SiteMetric.views) // WHEN let percentage = viewModel.percentage @@ -91,7 +91,7 @@ struct TrendViewModelTests { @Test("Percentage with negative values") func testPercentageWithNegativeValues() { // GIVEN/WHEN - let viewModel = TrendViewModel(currentValue: -50, previousValue: -100, metric: .views) + let viewModel = TrendViewModel(currentValue: -50, previousValue: -100, metric: SiteMetric.views) // THEN #expect(viewModel.percentage == 0.5) @@ -128,7 +128,7 @@ struct TrendViewModelTests { ]) func testFormattedPercentage(current: Int, previous: Int, expected: String) { // GIVEN - let viewModel = TrendViewModel(currentValue: current, previousValue: previous, metric: .views) + let viewModel = TrendViewModel(currentValue: current, previousValue: previous, metric: SiteMetric.views) // WHEN let formatted = viewModel.formattedPercentage @@ -144,9 +144,9 @@ struct TrendViewModelTests { let minInt = Int.min // WHEN - let viewModel1 = TrendViewModel(currentValue: maxInt, previousValue: 0, metric: .views) - let viewModel2 = TrendViewModel(currentValue: 0, previousValue: minInt, metric: .views) - let viewModel3 = TrendViewModel(currentValue: maxInt, previousValue: maxInt, metric: .views) + let viewModel1 = TrendViewModel(currentValue: maxInt, previousValue: 0, metric: SiteMetric.views) + let viewModel2 = TrendViewModel(currentValue: 0, previousValue: minInt, metric: SiteMetric.views) + let viewModel3 = TrendViewModel(currentValue: maxInt, previousValue: maxInt, metric: SiteMetric.views) // THEN #expect(viewModel1.sign == "+") diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 00cfe386c45d..e3ca391f9ada 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -1,6 +1,6 @@ 26.7 ----- - +* [**] Stats: Add new "Adds" tab to show WordAdds earnings and stats [#25165] 26.6 ----- diff --git a/Tests/WordPressKitTests/WordPressKitTests/Mock Data/stats-wordads-earnings.json b/Tests/WordPressKitTests/WordPressKitTests/Mock Data/stats-wordads-earnings.json new file mode 100644 index 000000000000..39cb13c85931 --- /dev/null +++ b/Tests/WordPressKitTests/WordPressKitTests/Mock Data/stats-wordads-earnings.json @@ -0,0 +1,38 @@ +{ + "ID": 456123789, + "name": "Example", + "URL": "https://example.wordpress.com", + "earnings": { + "total_earnings": "42.67", + "total_amount_owed": "38.40", + "wordads": { + "2025-12": { + "amount": 15.25, + "status": "0", + "pageviews": "3420" + }, + "2025-11": { + "amount": 12.80, + "status": "0", + "pageviews": "2890" + }, + "2025-10": { + "amount": 8.45, + "status": "0", + "pageviews": "1950" + }, + "2025-09": { + "amount": 6.17, + "status": "0", + "pageviews": "1420" + }, + "2025-08": { + "amount": 5.50, + "status": "1", + "pageviews": "1200" + } + }, + "sponsored": [], + "adjustment": [] + } +} diff --git a/Tests/WordPressKitTests/WordPressKitTests/Mock Data/stats-wordads-month.json b/Tests/WordPressKitTests/WordPressKitTests/Mock Data/stats-wordads-month.json new file mode 100644 index 000000000000..1cab780cbbf9 --- /dev/null +++ b/Tests/WordPressKitTests/WordPressKitTests/Mock Data/stats-wordads-month.json @@ -0,0 +1,36 @@ +{ + "date": "2026-01-31", + "unit": "month", + "fields": [ + "period", + "impressions", + "revenue", + "cpm" + ], + "data": [ + [ + "2025-10-01", + 14, + 0, + 0 + ], + [ + "2025-11-01", + 72, + 0, + 0 + ], + [ + "2025-12-01", + 174, + 0.01, + 0.06 + ], + [ + "2026-01-01", + 92, + 0, + 0 + ] + ] +} diff --git a/Tests/WordPressKitTests/WordPressKitTests/Tests/StatsRemoteV2Tests.swift b/Tests/WordPressKitTests/WordPressKitTests/Tests/StatsRemoteV2Tests.swift index 18934ac69286..546e6ac59083 100644 --- a/Tests/WordPressKitTests/WordPressKitTests/Tests/StatsRemoteV2Tests.swift +++ b/Tests/WordPressKitTests/WordPressKitTests/Tests/StatsRemoteV2Tests.swift @@ -28,6 +28,8 @@ class StatsRemoteV2Tests: RemoteTestCase, RESTTestable { let getStatsSummaryFilename = "stats-summary.json" let getArchivesDataFilename = "stats-archives-data.json" let getEmailOpensFilename = "stats-email-opens.json" + let getWordAdsMonthMockFilename = "stats-wordads-month.json" + let getWordAdsEarningsFilename = "stats-wordads-earnings.json" // MARK: - Properties @@ -46,6 +48,8 @@ class StatsRemoteV2Tests: RemoteTestCase, RESTTestable { var siteStatsSummaryEndpoint: String { return "sites/\(siteID)/stats/summary/" } var siteArchivesDataEndpoint: String { return "sites/\(siteID)/stats/archives" } var siteEmailOpensEndpoint: String { return "sites/\(siteID)/stats/opens/emails/231/rate" } + var siteWordAdsEndpoint: String { return "sites/\(siteID)/wordads/stats" } + var siteWordAdsEarningsEndpoint: String { return "sites/\(siteID)/wordads/earnings" } func toggleSpamStateEndpoint(for referrerDomain: String, markAsSpam: Bool) -> String { let action = markAsSpam ? "new" : "delete" @@ -854,4 +858,118 @@ class StatsRemoteV2Tests: RemoteTestCase, RESTTestable { waitForExpectations(timeout: timeout, handler: nil) } + + func testWordAdsMonthlyData() throws { + let expect = expectation(description: "It should return WordAds data for months") + + stubRemoteResponse(siteWordAdsEndpoint, filename: getWordAdsMonthMockFilename, contentType: .ApplicationJSON) + + let jan31 = DateComponents(year: 2026, month: 1, day: 31) + let date = Calendar.autoupdatingCurrent.date(from: jan31)! + + var currentResponse: StatsWordAdsResponse? + remote.getData(for: .month, endingOn: date) { (response: StatsWordAdsResponse?, error: Error?) in + XCTAssertNil(error) + XCTAssertNotNil(response) + + currentResponse = response + expect.fulfill() + } + waitForExpectations(timeout: timeout, handler: nil) + + let response = try XCTUnwrap(currentResponse) + + let data = response.data + guard data.count == 4 else { + XCTFail("Data should have 4 elements") + return + } + + // First data point + XCTAssertEqual(data[0].impressions, 14) + XCTAssertEqual(data[0].revenue, 0) + XCTAssertEqual(data[0].cpm, 0) + + // Second data point + XCTAssertEqual(data[1].impressions, 72) + XCTAssertEqual(data[1].revenue, 0) + XCTAssertEqual(data[1].cpm, 0) + + // Third data point (has non-zero revenue and CPM) + XCTAssertEqual(data[2].impressions, 174) + XCTAssertEqual(data[2].revenue, 0.01) + XCTAssertEqual(data[2].cpm, 0.06) + + // Fourth data point + XCTAssertEqual(data[3].impressions, 92) + XCTAssertEqual(data[3].revenue, 0) + XCTAssertEqual(data[3].cpm, 0) + + // Test subscript access + XCTAssertEqual(data[2][.impressions], 174.0) + XCTAssertEqual(data[2][.revenue], 0.01) + XCTAssertEqual(data[2][.cpm], 0.06) + } + + func testWordAdsEarnings() async throws { + stubRemoteResponse(siteWordAdsEarningsEndpoint, filename: getWordAdsEarningsFilename, contentType: .ApplicationJSON) + + let response = try await remote.getWordAdsEarnings() + + // Test total earnings + XCTAssertEqual(response.totalEarnings, Decimal(string: "42.67")) + XCTAssertEqual(response.totalAmountOwed, Decimal(string: "38.40")) + + // Test monthly earnings count + XCTAssertEqual(response.wordAdsEarnings.count, 5) + + // Test monthly earnings are sorted by period descending (most recent first) + XCTAssertEqual(response.wordAdsEarnings[0].period, StatsWordAdsEarningsResponse.Period(year: 2025, month: 12)) + XCTAssertEqual(response.wordAdsEarnings[1].period, StatsWordAdsEarningsResponse.Period(year: 2025, month: 11)) + XCTAssertEqual(response.wordAdsEarnings[2].period, StatsWordAdsEarningsResponse.Period(year: 2025, month: 10)) + XCTAssertEqual(response.wordAdsEarnings[3].period, StatsWordAdsEarningsResponse.Period(year: 2025, month: 9)) + + // Test first month (December 2025) + let decEarnings = response.wordAdsEarnings[0] + XCTAssertEqual(decEarnings.period.year, 2025) + XCTAssertEqual(decEarnings.period.month, 12) + XCTAssertEqual(decEarnings.amount, Decimal(string: "15.25")) + XCTAssertEqual(decEarnings.status, .outstanding) + XCTAssertEqual(decEarnings.pageviews, "3420") + + // Test second month (November 2025) + let novEarnings = response.wordAdsEarnings[1] + XCTAssertEqual(novEarnings.period.year, 2025) + XCTAssertEqual(novEarnings.period.month, 11) + XCTAssertEqual(novEarnings.amount, Decimal(string: "12.80")) + XCTAssertEqual(novEarnings.status, .outstanding) + XCTAssertEqual(novEarnings.pageviews, "2890") + + // Test third month (October 2025) + let octEarnings = response.wordAdsEarnings[2] + XCTAssertEqual(octEarnings.period.year, 2025) + XCTAssertEqual(octEarnings.period.month, 10) + XCTAssertEqual(octEarnings.amount, Decimal(string: "8.45")) + XCTAssertEqual(octEarnings.status, .outstanding) + XCTAssertEqual(octEarnings.pageviews, "1950") + + // Test fourth month (September 2025) + let sepEarnings = response.wordAdsEarnings[3] + XCTAssertEqual(sepEarnings.period.year, 2025) + XCTAssertEqual(sepEarnings.period.month, 9) + XCTAssertEqual(sepEarnings.amount, Decimal(string: "6.17")) + XCTAssertEqual(sepEarnings.status, .outstanding) + XCTAssertEqual(sepEarnings.pageviews, "1420") + + // Test fifth month (August 2025) - with paid status + let augEarnings = response.wordAdsEarnings[4] + XCTAssertEqual(augEarnings.period.year, 2025) + XCTAssertEqual(augEarnings.period.month, 8) + XCTAssertEqual(augEarnings.amount, Decimal(string: "5.50")) + XCTAssertEqual(augEarnings.status, .paid) + XCTAssertEqual(augEarnings.pageviews, "1200") + + // Test Period string conversion + XCTAssertEqual(decEarnings.period.string, "2025-12") + } } diff --git a/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent+JetpackStats.swift b/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent+JetpackStats.swift index 093fa3015a33..2b37c76cf3fc 100644 --- a/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent+JetpackStats.swift +++ b/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent+JetpackStats.swift @@ -9,6 +9,7 @@ extension StatsEvent { case .trafficTabShown: .jetpackStatsTrafficTabShown case .realtimeTabShown: .jetpackStatsRealtimeTabShown case .subscribersTabShown: .jetpackStatsSubscribersTabShown + case .adsTabShown: .jetpackStatsAdsTabShown case .postDetailsScreenShown: .jetpackStatsPostDetailsScreenShown case .authorStatsScreenShown: .jetpackStatsAuthorStatsScreenShown case .archiveStatsScreenShown: .jetpackStatsArchiveStatsScreenShown diff --git a/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift b/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift index 3eb4c046d55d..9671298f39c3 100644 --- a/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift +++ b/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift @@ -641,6 +641,7 @@ import WordPressShared case jetpackStatsTrafficTabShown case jetpackStatsRealtimeTabShown case jetpackStatsSubscribersTabShown + case jetpackStatsAdsTabShown case jetpackStatsPostDetailsScreenShown case jetpackStatsAuthorStatsScreenShown case jetpackStatsArchiveStatsScreenShown @@ -1789,6 +1790,8 @@ import WordPressShared return "jetpack_stats_realtime_tab_shown" case .jetpackStatsSubscribersTabShown: return "jetpack_stats_subscribers_tab_shown" + case .jetpackStatsAdsTabShown: + return "jetpack_stats_ads_tab_shown" case .jetpackStatsPostDetailsScreenShown: return "jetpack_stats_post_details_screen_shown" case .jetpackStatsAuthorStatsScreenShown: diff --git a/WordPress/Classes/ViewRelated/Stats/SiteStatsDashboardViewController.swift b/WordPress/Classes/ViewRelated/Stats/SiteStatsDashboardViewController.swift index 530642da72aa..8c1213e9dac5 100644 --- a/WordPress/Classes/ViewRelated/Stats/SiteStatsDashboardViewController.swift +++ b/WordPress/Classes/ViewRelated/Stats/SiteStatsDashboardViewController.swift @@ -147,11 +147,11 @@ public class SiteStatsDashboardViewController: UIViewController { return StatsSubscribersViewController(viewModel: viewModel) }() - private lazy var adsViewController: UIViewController = { - let adsView = AdsTabView() - let hostingController = UIHostingController(rootView: adsView) - hostingController.view.backgroundColor = .systemBackground - return hostingController + private lazy var adsViewController: UIViewController? = { + guard let blog = Self.currentBlog() else { + return nil + } + return StatsHostingViewController.makeAdsViewController(blog: blog, parentViewController: self) }() // MARK: - View @@ -192,7 +192,9 @@ public class SiteStatsDashboardViewController: UIViewController { switch currentSelectedTab { case .insights: - parent?.navigationItem.rightBarButtonItem = manageInsightsButton + parent?.navigationItem.trailingItemGroups = [ + UIBarButtonItemGroup.fixedGroup(items: [manageInsightsButton]) + ] case .traffic: // Always show the menu for switching between stats experiences statsMenuButton.menu = createStatsMenu() @@ -212,9 +214,9 @@ public class SiteStatsDashboardViewController: UIViewController { } } case .ads: - parent?.navigationItem.rightBarButtonItem = nil + parent?.navigationItem.trailingItemGroups = [] default: - parent?.navigationItem.rightBarButtonItem = nil + parent?.navigationItem.trailingItemGroups = [] } } @@ -461,7 +463,9 @@ private extension SiteStatsDashboardViewController { } case .ads: if oldSelectedTab != .ads || containerIsEmpty { - showChildViewController(adsViewController) + if let adsViewController { + showChildViewController(adsViewController) + } } } } diff --git a/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift b/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift index 471ba6c9e6b8..66591cbbde8f 100644 --- a/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift +++ b/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift @@ -41,6 +41,20 @@ class StatsHostingViewController: UIViewController { statsVC.navigationItem.largeTitleDisplayMode = .never return statsVC } + + static func makeAdsViewController(blog: Blog, parentViewController: UIViewController) -> UIViewController? { + guard let context = StatsContext(blog: blog) else { + return nil + } + + let adsView = AdsTabView( + context: context, + router: StatsRouter(viewController: parentViewController) + ) + let hostingController = UIHostingController(rootView: adsView) + hostingController.view.backgroundColor = .systemBackground + return hostingController + } } extension StatsContext { diff --git a/WordPress/Classes/ViewRelated/Stats/StatsViewController.m b/WordPress/Classes/ViewRelated/Stats/StatsViewController.m index 05c2afbd76a2..aee880251685 100644 --- a/WordPress/Classes/ViewRelated/Stats/StatsViewController.m +++ b/WordPress/Classes/ViewRelated/Stats/StatsViewController.m @@ -30,7 +30,7 @@ - (void)viewDidLoad [super viewDidLoad]; self.view.backgroundColor = [UIColor systemGroupedBackgroundColor]; - self.navigationItem.title = NSLocalizedString(@"Stats", @"Stats window title"); + [self updateNavigationTitle]; self.siteStatsDashboardVC = [[SiteStatsDashboardViewController alloc] init]; @@ -54,6 +54,8 @@ - (void)viewDidLoad [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(reachabilityChanged:) name:kTMReachabilityChangedNotification object:ReachabilityUtils.internetReachability]; + [self registerForTraitChanges:@[UITraitHorizontalSizeClass.class] withAction:@selector(updateNavigationTitle)]; + [self initStats]; } @@ -64,6 +66,15 @@ - (void)viewDidAppear:(BOOL)animated [ObjCBridge incrementSignificantEvent]; } +- (void)updateNavigationTitle +{ + if (self.traitCollection.horizontalSizeClass == UIUserInterfaceSizeClassCompact) { + self.navigationItem.title = NSLocalizedString(@"Stats", @"Stats window title"); + } else { + self.navigationItem.title = nil; + } +} + - (void)setBlog:(Blog *)blog { _blog = blog;