Skip to content
This repository was archived by the owner on Sep 15, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 21 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
4 changes: 2 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ let package = Package(
targets: [
.binaryTarget(
name: "WordPressKit",
url: "https://github.com/user-attachments/files/20895757/WordPressKit.zip",
checksum: "b08eaf182f0399303aadccb1a6dad6cad294a9c8d123d920889b15950c85e08f"
url: "https://github.com/user-attachments/files/21488685/WordPressKit.zip",
checksum: "c591b9d12fdfeeedf7f31d884c6ce751722a37ae8ebb396a511fdb045698bccd"
),
]
)
42 changes: 42 additions & 0 deletions Sources/WordPressKit/Models/Stats/Emails/StatsEmailOpensData.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import Foundation

public struct StatsEmailOpensData: Decodable, Equatable {
public let totalSends: Int?
public let uniqueOpens: Int?
public let totalOpens: Int?
public let opensRate: Double?
Copy link
Contributor

Choose a reason for hiding this comment

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

Should all these properties be null? Will the response ever be null, like no total_sends or total_send: null in the response?

Copy link
Contributor Author

@kean kean Aug 1, 2025

Choose a reason for hiding this comment

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

I'd change it, but it's a throw-away code, so I'm not sure it's worth reworking it and the usage. I'm not sure I have any energy left at this point 😪


public init(totalSends: Int?, uniqueOpens: Int?, totalOpens: Int?, opensRate: Double?) {
self.totalSends = totalSends
self.uniqueOpens = uniqueOpens
self.totalOpens = totalOpens
self.opensRate = opensRate
}

private enum CodingKeys: String, CodingKey {
case totalSends = "total_sends"
case uniqueOpens = "unique_opens"
case totalOpens = "total_opens"
case opensRate = "opens_rate"
}

public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
totalSends = try container.decodeIfPresent(Int.self, forKey: .totalSends)
uniqueOpens = try container.decodeIfPresent(Int.self, forKey: .uniqueOpens)
totalOpens = try container.decodeIfPresent(Int.self, forKey: .totalOpens)
opensRate = try container.decodeIfPresent(Double.self, forKey: .opensRate)
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we need this function? It's the same as the default derived implementation, if I'm not mistaken?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

No really - removing.

}

extension StatsEmailOpensData {
public init?(jsonDictionary: [String: AnyObject]) {
Copy link
Contributor

Choose a reason for hiding this comment

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

throw instead of silencing the error?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I auto-generated this code with the rest of the file as a reference. It is consistent with the rest of StatsServiceRemoteV2 and how it's used. I'd suggest keeping it as it's throw-away code anyway.

do {
let jsonData = try JSONSerialization.data(withJSONObject: jsonDictionary, options: [])
let decoder = JSONDecoder()
self = try decoder.decode(Self.self, from: jsonData)
} catch {
return nil
}
}
}
145 changes: 136 additions & 9 deletions Sources/WordPressKit/Models/Stats/StatsPostDetails.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import Foundation

public struct StatsPostDetails: Equatable {
public let fetchedDate: Date
public let totalViewsCount: Int
Expand All @@ -6,6 +8,81 @@ public struct StatsPostDetails: Equatable {
public let dailyAveragesPerMonth: [StatsPostViews]
public let monthlyBreakdown: [StatsPostViews]
public let lastTwoWeeks: [StatsPostViews]
public let data: [StatsPostViews]

public let highestMonth: Int?
public let highestDayAverage: Int?
public let highestWeekAverage: Int?

public let yearlyTotals: [Int: Int]
public let overallAverages: [Int: Int]

public let fields: [String]?

public let post: Post?

public struct Post: Equatable {
public let postID: Int
public let title: String
public let authorID: String?
public let dateGMT: Date?
public let content: String?
public let excerpt: String?
public let status: String?
public let commentStatus: String?
public let password: String?
public let name: String?
public let modifiedGMT: Date?
public let contentFiltered: String?
public let parent: Int?
public let guid: String?
public let type: String?
public let mimeType: String?
public let commentCount: String?
public let permalink: String?

init?(jsonDictionary: [String: AnyObject]) {
guard
let postID = jsonDictionary["ID"] as? Int,
let title = jsonDictionary["post_title"] as? String
else {
return nil
}

let dateFormatter = DateFormatter()
dateFormatter.locale = Locale(identifier: "en_US_POSIX")
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
Copy link
Contributor

Choose a reason for hiding this comment

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

You'd need to set the timeZone property to parse GMT date strings that do not have a time zone in them, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is an existing issue across all existing decodes in this service. The date is actually returned in the site time zone. The decoders don't know what is the time zone of the site. The response also doesn't contain it. So it end's up creating a wrong timestamp in your local time zone.

I added code to map it in StatsService https://github.com/wordpress-mobile/WordPress-iOS/blob/b82374372485a73d0a3f49bc5cbc3886a3179767/Modules/Sources/JetpackStats/Services/StatsService.swift#L355, but I think I made a mistake at one of the layers. I opened a ticket and will debug it later on Monday: https://linear.app/a8c/issue/CMM-642/dates-are-in-the-wrong-timezone. The fix needs to be on the app level. I want to keep the WPKit behavior as is so it doesn't break the legacy screens.


var dateGMT: Date?
var modifiedGMT: Date?

if let postDateGMTString = jsonDictionary["post_date_gmt"] as? String {
dateGMT = dateFormatter.date(from: postDateGMTString)
}
if let postModifiedGMTString = jsonDictionary["post_modified_gmt"] as? String {
modifiedGMT = dateFormatter.date(from: postModifiedGMTString)
}

self.postID = postID
self.title = title
self.authorID = jsonDictionary["post_author"] as? String
self.dateGMT = dateGMT
self.content = jsonDictionary["post_content"] as? String
self.excerpt = jsonDictionary["post_excerpt"] as? String
self.status = jsonDictionary["post_status"] as? String
self.commentStatus = jsonDictionary["comment_status"] as? String
self.password = jsonDictionary["post_password"] as? String
self.name = jsonDictionary["post_name"] as? String
self.modifiedGMT = modifiedGMT
self.contentFiltered = jsonDictionary["post_content_filtered"] as? String
self.parent = jsonDictionary["post_parent"] as? Int
self.guid = jsonDictionary["guid"] as? String
self.type = jsonDictionary["post_type"] as? String
self.mimeType = jsonDictionary["post_mime_type"] as? String
self.commentCount = jsonDictionary["comment_count"] as? String
self.permalink = jsonDictionary["permalink"] as? String
}
}
}

public struct StatsWeeklyBreakdown: Equatable {
Expand All @@ -15,6 +92,7 @@ public struct StatsWeeklyBreakdown: Equatable {
public let totalViewsCount: Int
public let averageViewsCount: Int
public let changePercentage: Double
public let isChangeInfinity: Bool

public let days: [StatsPostViews]
}
Expand All @@ -26,7 +104,7 @@ public struct StatsPostViews: Equatable {
}

extension StatsPostDetails {
init?(jsonDictionary: [String: AnyObject]) {
public init?(jsonDictionary: [String: AnyObject]) {
guard
let fetchedDateString = jsonDictionary["date"] as? String,
let date = type(of: self).dateFormatter.date(from: fetchedDateString),
Expand All @@ -35,13 +113,15 @@ extension StatsPostDetails {
let monthlyAverages = jsonDictionary["averages"] as? [String: AnyObject],
let recentWeeks = jsonDictionary["weeks"] as? [[String: AnyObject]],
let data = jsonDictionary["data"] as? [[Any]]
else {
return nil
else {
return nil
}

self.fetchedDate = date
self.totalViewsCount = totalViewsCount

self.data = StatsPostViews.mapDailyData(data: data)

// It's very hard to describe the format of this response. I tried to make the parsing
// as nice and readable as possible, but in all honestly it's still pretty nasty.
// If you want to see an example response to see how weird this response is, check out
Expand All @@ -50,6 +130,42 @@ extension StatsPostDetails {
self.monthlyBreakdown = StatsPostViews.mapMonthlyBreakdown(jsonDictionary: monthlyBreakdown)
self.dailyAveragesPerMonth = StatsPostViews.mapMonthlyBreakdown(jsonDictionary: monthlyAverages)
self.lastTwoWeeks = StatsPostViews.mapDailyData(data: Array(data.suffix(14)))

// Parse new fields
self.highestMonth = jsonDictionary["highest_month"] as? Int
self.highestDayAverage = jsonDictionary["highest_day_average"] as? Int
self.highestWeekAverage = jsonDictionary["highest_week_average"] as? Int

self.fields = jsonDictionary["fields"] as? [String]

// Parse yearly totals
var yearlyTotals: [Int: Int] = [:]
if let years = monthlyBreakdown as? [String: [String: AnyObject]] {
for (yearKey, yearData) in years {
if let yearInt = Int(yearKey), let total = yearData["total"] as? Int {
yearlyTotals[yearInt] = total
}
}
}
self.yearlyTotals = yearlyTotals

// Parse overall averages
var overallAverages: [Int: Int] = [:]
if let averages = monthlyAverages as? [String: [String: AnyObject]] {
for (yearKey, yearData) in averages {
if let yearInt = Int(yearKey), let overall = yearData["overall"] as? Int {
overallAverages[yearInt] = overall
}
}
}
self.overallAverages = overallAverages

// Parse post object using the new Post model
if let postDict = jsonDictionary["post"] as? [String: AnyObject] {
self.post = Post(jsonDictionary: postDict)
} else {
self.post = nil
}
}

static var dateFormatter: DateFormatter {
Expand Down Expand Up @@ -93,19 +209,30 @@ extension StatsPostViews {
let totalViews = $0["total"] as? Int,
let averageViews = $0["average"] as? Int,
let days = $0["days"] as? [[String: AnyObject]]
else {
return nil
else {
return nil
}

let change = ($0["change"] as? Double) ?? 0.0
var change: Double = 0.0
var isChangeInfinity = false

if let changeValue = $0["change"] {
if let changeDict = changeValue as? [String: AnyObject],
let isInfinity = changeDict["isInfinity"] as? Bool {
isChangeInfinity = isInfinity
change = isInfinity ? Double.infinity : 0.0
} else if let changeDouble = changeValue as? Double {
change = changeDouble
}
}

let mappedDays: [StatsPostViews] = days.compactMap {
guard
let dayString = $0["day"] as? String,
let date = StatsPostDetails.dateFormatter.date(from: dayString),
let viewsCount = $0["count"] as? Int
else {
return nil
else {
return nil
}

return StatsPostViews(period: .day,
Expand All @@ -122,9 +249,9 @@ extension StatsPostViews {
totalViewsCount: totalViews,
averageViewsCount: averageViews,
changePercentage: change,
isChangeInfinity: isChangeInfinity,
days: mappedDays)
}

}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ extension StatsSubscribersSummaryData: StatsTimeIntervalData {
return "stats/subscribers"
}

static var hourlyDateFormatter: DateFormatter {
let df = DateFormatter()
df.locale = Locale(identifier: "en_US_POS")
Copy link
Contributor

Choose a reason for hiding this comment

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

Is the id supposed to be en_US_POSIX?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Updated. This code is unused – the new stats don't request hourly data for subscribers and there is no hourly data for subscribers. The existing formatters in this screen use en_US_POS. I didn't want to make changes to the existing code in the scope of this change.

df.dateFormat = "yyyy-MM-dd HH:mm:ss"
return df
}

static var dateFormatter: DateFormatter = {
let df = DateFormatter()
df.locale = Locale(identifier: "en_US_POS")
Expand Down Expand Up @@ -71,6 +78,9 @@ extension StatsSubscribersSummaryData: StatsTimeIntervalData {

private static func parsedDate(from dateString: String, for period: StatsPeriodUnit) -> Date? {
switch period {
case .hour:
// Example: "2025-07-17 09:00:00" (in a site timezone)
return self.hourlyDateFormatter.date(from: dateString)
case .week:
return self.weeksDateFormatter.date(from: dateString)
case .day, .month, .year:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import Foundation

public struct StatsArchiveTimeIntervalData {
public let period: StatsPeriodUnit
public let unit: StatsPeriodUnit?
public let periodEndDate: Date
public let summary: [String: [StatsArchiveItem]]

public init(period: StatsPeriodUnit,
unit: StatsPeriodUnit? = nil,
periodEndDate: Date,
summary: [String: [StatsArchiveItem]]) {
self.period = period
self.unit = unit
self.periodEndDate = periodEndDate
self.summary = summary
}
}

public struct StatsArchiveItem {
public let href: String
public let value: String
public let views: Int

public init(href: String, value: String, views: Int) {
self.href = href
self.value = value
self.views = views
}
}

extension StatsArchiveTimeIntervalData: StatsTimeIntervalData {
public static var pathComponent: String {
return "stats/archives"
}

public static func queryProperties(with date: Date, period: StatsPeriodUnit, maxCount: Int) -> [String: String] {
return ["max": 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 summary = jsonDictionary["summary"] as? [String: AnyObject] else {
return nil
}

self.period = period
self.unit = unit
self.periodEndDate = date
self.summary = {
var map: [String: [StatsArchiveItem]] = [:]
for (key, value) in summary {
let items = (value as? [[String: AnyObject]])?.compactMap {
StatsArchiveItem(jsonDictionary: $0)
} ?? []
if !items.isEmpty {
map[key] = items
}
}
return map
}()
}
}

private extension StatsArchiveItem {
init?(jsonDictionary: [String: AnyObject]) {
guard
let href = jsonDictionary["href"] as? String,
let value = jsonDictionary["value"] as? String,
let views = jsonDictionary["views"] as? Int
else {
return nil
}

self.href = href
self.value = value
self.views = views
}
}
Loading