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..1ba6a8abeaa3 --- /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 { + 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..11587e3ee98d --- /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.green, + 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..dbb6eb9aebd6 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() @@ -491,6 +545,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..62891ead3504 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,79 @@ 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 + } + + 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 +398,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 +429,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 +454,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 +492,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 +518,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..dfaf41edfd8a 100644 --- a/Modules/Sources/JetpackStats/Services/StatsServiceProtocol.swift +++ b/Modules/Sources/JetpackStats/Services/StatsServiceProtocol.swift @@ -8,6 +8,7 @@ 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 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..2433dc8cebdc 100644 --- a/Modules/Sources/JetpackStats/Strings.swift +++ b/Modules/Sources/JetpackStats/Strings.swift @@ -53,6 +53,12 @@ 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 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..272511efb5d3 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: 48 + case .day: 30 + case .week: 12 + case .month: 12 + case .year: 10 + } + } } 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..103f1da62ecc 100644 --- a/Modules/Sources/JetpackStats/Utilities/StatsDateRange.swift +++ b/Modules/Sources/JetpackStats/Utilities/StatsDateRange.swift @@ -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: Calendar.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: Calendar.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.. 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 .week: "yyyy'W'MM'W'dd" + case .day, .month, .year: "yyyy-MM-dd" + } + }() + return formatter +} 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/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..4a66a0a2c52f 100644 --- a/Tests/WordPressKitTests/WordPressKitTests/Tests/StatsRemoteV2Tests.swift +++ b/Tests/WordPressKitTests/WordPressKitTests/Tests/StatsRemoteV2Tests.swift @@ -28,6 +28,7 @@ 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" // MARK: - Properties @@ -46,6 +47,7 @@ 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" } func toggleSpamStateEndpoint(for referrerDomain: String, markAsSpam: Bool) -> String { let action = markAsSpam ? "new" : "delete" @@ -854,4 +856,58 @@ 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) + + + } }