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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Modules/Sources/JetpackStats/Services/Data/DataPoint.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
20 changes: 20 additions & 0 deletions Modules/Sources/JetpackStats/Services/Data/MetricType.swift
Original file line number Diff line number Diff line change
@@ -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
}
9 changes: 3 additions & 6 deletions Modules/Sources/JetpackStats/Services/Data/SiteMetric.swift
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
}
}
70 changes: 70 additions & 0 deletions Modules/Sources/JetpackStats/Services/Data/WordAdsMetric.swift
Original file line number Diff line number Diff line change
@@ -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]
}
Original file line number Diff line number Diff line change
@@ -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]]
}
35 changes: 35 additions & 0 deletions Modules/Sources/JetpackStats/Services/Data/WordAdsMetricsSet.swift
Original file line number Diff line number Diff line change
@@ -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
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
@preconcurrency import WordPressKit

extension WordPressKit.StatsServiceRemoteV2 {
/// A modern variant of `WordPressKit.StatsTimeIntervalData` API that supports
/// custom date periods.
func getData<TimeStatsType: WordPressKit.StatsTimeIntervalData>(
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<TimeStatsType: WordPressKit.StatsTimeIntervalData>(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most of this code was simply moved from the bottom of StatsService.swift. The only addition is the method:

/// A legacy variant of `WordPressKit.StatsTimeIntervalData` API that supports
    /// only support setting the target date and the quantity of periods to return.
    func getData<TimeStatsType: WordPressKit.StatsTimeIntervalData>(
        date: Date,
        unit: WordPressKit.StatsPeriodUnit,
        quantity: Int
     ) async throws -> TimeStatsType where TimeStatsType: Sendable {

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<InsightType: StatsInsightData>(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
}
}
}
Loading