-
Notifications
You must be signed in to change notification settings - Fork 16
New Stats #843
New Stats #843
Changes from 21 commits
58092a4
8ad283d
8c33cf7
b17f5d8
ca75e34
064b128
5704cbb
e146c87
7e76969
8bf6082
0a0dac0
9139c2f
102dc8d
392d2d3
a8b31bc
32f9410
6588ca8
c31ddf1
2613f21
67db92e
c78d1f2
1b4d284
1e06e95
440d94e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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? | ||
|
|
||
| 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) | ||
| } | ||
|
||
| } | ||
|
|
||
| extension StatsEmailOpensData { | ||
| public init?(jsonDictionary: [String: AnyObject]) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| do { | ||
| let jsonData = try JSONSerialization.data(withJSONObject: jsonDictionary, options: []) | ||
| let decoder = JSONDecoder() | ||
| self = try decoder.decode(Self.self, from: jsonData) | ||
| } catch { | ||
| return nil | ||
| } | ||
| } | ||
| } | ||
| 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 | ||
|
|
@@ -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" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You'd need to set the
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 { | ||
|
|
@@ -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] | ||
| } | ||
|
|
@@ -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), | ||
|
|
@@ -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 | ||
|
|
@@ -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 { | ||
|
|
@@ -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, | ||
|
|
@@ -122,9 +249,9 @@ extension StatsPostViews { | |
| totalViewsCount: totalViews, | ||
| averageViewsCount: averageViews, | ||
| changePercentage: change, | ||
| isChangeInfinity: isChangeInfinity, | ||
| days: mappedDays) | ||
| } | ||
|
|
||
| } | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,6 +17,13 @@ extension StatsSubscribersSummaryData: StatsTimeIntervalData { | |
| return "stats/subscribers" | ||
| } | ||
|
|
||
| static var hourlyDateFormatter: DateFormatter { | ||
| let df = DateFormatter() | ||
| df.locale = Locale(identifier: "en_US_POS") | ||
|
||
| df.dateFormat = "yyyy-MM-dd HH:mm:ss" | ||
| return df | ||
| } | ||
|
|
||
| static var dateFormatter: DateFormatter = { | ||
| let df = DateFormatter() | ||
| df.locale = Locale(identifier: "en_US_POS") | ||
|
|
@@ -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: | ||
|
|
||
| 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 | ||
| } | ||
| } |
There was a problem hiding this comment.
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_sendsortotal_send: nullin the response?Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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 😪