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
60 changes: 60 additions & 0 deletions Modules/Sources/JetpackStats/Services/PostLikesStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import Foundation
@preconcurrency import WordPressKit

/// A single liker in the shape the app's shared likes cache expects.
///
/// Built directly from the API response so the cache receives full-fidelity
/// data even though the stats UI renders only name and avatar: the Likes list
/// cell shows an `@username` subtitle, and the cache sorts and pages by the
/// parsed like date, so dropping either field here would degrade seeded rows.
/// The date is kept as the server-formatted string because the cache stores
/// that string verbatim as its paging cursor.
public struct PostLikeSeed: Equatable, Sendable {
public let userID: Int
public let displayName: String
public let username: String?
public let avatarURL: String?
public let dateLikedString: String?

public init(
userID: Int,
displayName: String,
username: String?,
avatarURL: String?,
dateLikedString: String?
) {
self.userID = userID
self.displayName = displayName
self.username = username
self.avatarURL = avatarURL
self.dateLikedString = dateLikedString
}
}

extension PostLikeSeed {
init(remoteUser: RemoteLikeUser) {
self.init(
userID: remoteUser.userID?.intValue ?? 0,
displayName: remoteUser.displayName ?? remoteUser.username ?? "",
username: remoteUser.username,
avatarURL: remoteUser.avatarURL,
dateLikedString: remoteUser.dateLiked
)
}
}

/// App-injected sink that persists likers fetched by Post Stats into the
/// app's shared likes cache, so the Likes list screen can seed itself from
/// cache instead of starting empty. The package deliberately knows nothing
/// about the cache's implementation; `nil` (previews, mocks) disables seeding.
public protocol PostLikesStore: Sendable {
/// Persists the likers fetched by Post Stats for a post.
///
/// `totalCount` is the post's authoritative like count from the same fetch.
/// When it is `0` the store must clear any cached likers for the post: Post
/// Stats only ever seeds the first page, so a plain upsert of the empty
/// `likes` would leave stale rows that the Likes list would show under a
/// "0 likes" title (including offline, where its own refresh cannot run).
/// A positive `totalCount` upserts the partial seeds without purging.
func storeLikes(_ likes: [PostLikeSeed], totalCount: Int, forPost postID: Int) async
}
138 changes: 121 additions & 17 deletions Modules/Sources/JetpackStats/Services/StatsService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ actor StatsService: StatsServiceProtocol {
private let siteTimeZone: TimeZone
// Temporary
private var mocks: MockStatsService
private let postLikesStore: (any PostLikesStore)?

// Cache
private var siteStatsCache: [SiteStatsCacheKey: CachedEntity<SiteMetricsResponse>] = [:]
Expand Down Expand Up @@ -46,7 +47,7 @@ actor StatsService: StatsServiceProtocol {
}
}

init(siteID: Int, api: WordPressComRestApi, timeZone: TimeZone) {
init(siteID: Int, api: WordPressComRestApi, timeZone: TimeZone, postLikesStore: (any PostLikesStore)? = nil) {
self.siteID = siteID
self.api = api
self.service = StatsServiceRemoteV2(
Expand All @@ -56,6 +57,7 @@ actor StatsService: StatsServiceProtocol {
)
self.siteTimeZone = timeZone
self.mocks = MockStatsService(timeZone: timeZone)
self.postLikesStore = postLikesStore
}

// MARK: - StatsServiceProtocol
Expand All @@ -80,22 +82,37 @@ actor StatsService: StatsServiceProtocol {
return data
}

private func fetchSiteStats(interval: DateInterval, granularity: DateRangeGranularity) async throws -> SiteMetricsResponse {
private func fetchSiteStats(
interval: DateInterval,
granularity: DateRangeGranularity
) async throws -> SiteMetricsResponse {
let interval = convertDateIntervalSiteToLocal(interval)

if granularity == .hour {
// Hourly data is available only for "Views", so the service has to
// make a separate request to fetch the total metrics.
async let hourlyResponseTask: WordPressKit.StatsSiteMetricsResponse = service.getData(interval: interval, unit: .init(granularity), limit: 0)
async let dailyResponseTask: WordPressKit.StatsSiteMetricsResponse = service.getData(interval: interval, unit: .init(.day), limit: 0)
async let hourlyResponseTask: WordPressKit.StatsSiteMetricsResponse = service.getData(
interval: interval,
unit: .init(granularity),
limit: 0
)
async let dailyResponseTask: WordPressKit.StatsSiteMetricsResponse = service.getData(
interval: interval,
unit: .init(.day),
limit: 0
)

let (hourlyResponse, dailyResponse) = try await (hourlyResponseTask, dailyResponseTask)

var data = mapSiteMetricsResponse(hourlyResponse)
data.total = mapSiteMetricsResponse(dailyResponse).total
return data
} else {
let response: WordPressKit.StatsSiteMetricsResponse = try await service.getData(interval: interval, unit: .init(granularity), limit: 0)
let response: WordPressKit.StatsSiteMetricsResponse = try await service.getData(
interval: interval,
unit: .init(granularity),
limit: 0
)
return mapSiteMetricsResponse(response)
}
}
Expand Down Expand Up @@ -124,7 +141,10 @@ actor StatsService: StatsServiceProtocol {
try await service.getWordAdsEarnings()
}

private func fetchWordAdsStats(date: Date, granularity: DateRangeGranularity) async throws -> WordAdsMetricsResponse {
private func fetchWordAdsStats(
date: Date,
granularity: DateRangeGranularity
) async throws -> WordAdsMetricsResponse {
let localDate = convertDateSiteToLocal(date)

let response: WordPressKit.StatsWordAdsResponse = try await service.getData(
Expand All @@ -145,7 +165,10 @@ actor StatsService: StatsServiceProtocol {

let now = Date.now

func makeDataPoint(from data: WordPressKit.StatsWordAdsResponse.PeriodData, metric: WordPressKit.StatsWordAdsResponse.Metric) -> DataPoint? {
func makeDataPoint(
from data: WordPressKit.StatsWordAdsResponse.PeriodData,
metric: WordPressKit.StatsWordAdsResponse.Metric
) -> DataPoint? {
guard let value = data[metric] else {
return nil
}
Expand Down Expand Up @@ -180,16 +203,37 @@ actor StatsService: StatsServiceProtocol {
return WordAdsMetricsResponse(total: total, metrics: metrics)
}

func getTopListData(_ item: TopListItemType, metric: SiteMetric, interval: DateInterval, granularity: DateRangeGranularity, limit: Int?, options: TopListItemOptions) async throws -> TopListResponse {
func getTopListData(
_ item: TopListItemType,
metric: SiteMetric,
interval: DateInterval,
granularity: DateRangeGranularity,
limit: Int?,
options: TopListItemOptions
) async throws -> TopListResponse {
// Check cache first
let cacheKey = TopListCacheKey(item: item, metric: metric, options: options, interval: interval, granularity: granularity, limit: limit)
let cacheKey = TopListCacheKey(
item: item,
metric: metric,
options: options,
interval: interval,
granularity: granularity,
limit: limit
)
if let cached = topListCache[cacheKey], !cached.isExpired {
return cached.data
}

// Fetch fresh data
do {
let data = try await _getTopListData(item, metric: metric, interval: interval, granularity: granularity, limit: limit, options: options)
let data = try await _getTopListData(
item,
metric: metric,
interval: interval,
granularity: granularity,
limit: limit,
options: options
)

// Cache the result
// Historical data never expires (ttl = nil), current period data expires after 30 seconds
Expand All @@ -205,22 +249,36 @@ actor StatsService: StatsServiceProtocol {
// when there are no recoreded periods (happens when the entire requested
// period is _before_ the site creation).
if let error = error as? StatsServiceRemoteV2.ResponseError,
error == .emptySummary {
error == .emptySummary
{
return TopListResponse(items: [])
}
throw error
}
}

private func _getTopListData(_ item: TopListItemType, metric: SiteMetric, interval: DateInterval, granularity: DateRangeGranularity, limit: Int?, options: TopListItemOptions) async throws -> TopListResponse {
private func _getTopListData(
_ item: TopListItemType,
metric: SiteMetric,
interval: DateInterval,
granularity: DateRangeGranularity,
limit: Int?,
options: TopListItemOptions
) async throws -> TopListResponse {

func getData<T: WordPressKit.StatsTimeIntervalData>(
_ type: T.Type,
parameters: [String: String]? = nil
) async throws -> T where T: Sendable {
/// The `summarize: true` feature works correctly only with the `.day` granularity.
let interval = convertDateIntervalSiteToLocal(interval)
return try await service.getData(interval: interval, unit: .day, summarize: true, limit: limit ?? 0, parameters: parameters)
return try await service.getData(
interval: interval,
unit: .day,
summarize: true,
limit: limit ?? 0,
parameters: parameters
)
}

// Helper function to sort items by metric value (descending), then by displayName, and then by itemID for stable ordering
Expand Down Expand Up @@ -277,7 +335,11 @@ actor StatsService: StatsServiceProtocol {
}

let convertedInterval = convertDateIntervalSiteToLocal(interval)
let data = try await service.getDeviceStats(breakdown: breakdown, startDate: convertedInterval.start, endDate: convertedInterval.end)
let data = try await service.getDeviceStats(
breakdown: breakdown,
startDate: convertedInterval.start,
endDate: convertedInterval.end
)

// TEMPORARY WORKAROUND (CMM-1168):
// The screensize breakdown returns percentages (e.g., 73.8 for 73.8%), but SiteMetricsSet
Expand Down Expand Up @@ -402,7 +464,8 @@ actor StatsService: StatsServiceProtocol {
)

// Fetch likes using the REST API
let result = try await withCheckedThrowingContinuation { continuation in
let (result, seeds): (PostLikesData, [PostLikeSeed]) = try await withCheckedThrowingContinuation {
continuation in
postService.getLikesForPostID(
NSNumber(value: postID),
count: NSNumber(value: count),
Expand All @@ -418,18 +481,56 @@ actor StatsService: StatsServiceProtocol {
avatarURL: remoteLike.avatarURL.flatMap(URL.init)
)
}
let seeds = users.map(PostLikeSeed.init(remoteUser:))
let postLikes = PostLikesData(users: likeUsers, totalCount: found.intValue)
continuation.resume(returning: postLikes)
continuation.resume(returning: (postLikes, seeds))
},
failure: { error in
continuation.resume(throwing: error ?? StatsServiceError.unknown)
}
)
}

await storeSeeds(seeds, totalCount: result.totalCount, postID: postID)

return result
}

/// Seeds the shared likes cache with the just-fetched likers.
///
/// A positive `totalCount` seeds fire-and-forget: seeding real likers must
/// never delay or fail the UI, and a slightly late partial page is benign.
/// A confirmed zero result is awaited, because it purges the post's cache
/// and this method returns before Post Stats exposes the "0 likes" total: a
/// fast tap-through to the Likes list would otherwise snapshot stale seeded
/// rows that, offline, it never refetches or observes being deleted.
func storeSeeds(_ seeds: [PostLikeSeed], totalCount: Int, postID: Int) async {
let task = storeSeedsIfNeeded(seeds, totalCount: totalCount, postID: postID)
if totalCount == 0 {
await task?.value
}
}

/// Hands freshly fetched likers to the app's shared likes cache.
///
/// Write-only by design: Post Stats refetches on every screen entry (this
/// service instance is created per screen), and the shared cache carries
/// no total like count, so reading it back here would render a wrong
/// total in the likes strip. The write exists solely to seed the Likes
/// list screen. `totalCount` is forwarded so the store can clear the post's
/// cache on a confirmed zero-like result instead of leaving stale rows.
/// Returns the seeding task so callers can await it (see `storeSeeds`).
@discardableResult
func storeSeedsIfNeeded(_ seeds: [PostLikeSeed], totalCount: Int, postID: Int) -> Task<Void, Never>? {
guard let postLikesStore else {
return nil
}

return Task {
await postLikesStore.storeLikes(seeds, totalCount: totalCount, forPost: postID)
}
}

func getEmailOpens(for postID: Int) async throws -> StatsEmailOpensData {
try await service.getEmailOpens(for: postID)
}
Expand Down Expand Up @@ -518,7 +619,10 @@ actor StatsService: StatsServiceProtocol {

let now = Date.now

func makeDataPoint(from data: WordPressKit.StatsSiteMetricsResponse.PeriodData, metric: WordPressKit.StatsSiteMetricsResponse.Metric) -> DataPoint? {
func makeDataPoint(
from data: WordPressKit.StatsSiteMetricsResponse.PeriodData,
metric: WordPressKit.StatsSiteMetricsResponse.Metric
) -> DataPoint? {
guard let value = data[metric] else {
return nil
}
Expand Down
17 changes: 13 additions & 4 deletions Modules/Sources/JetpackStats/StatsContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,17 @@ public struct StatsContext: Sendable {
/// URL to upgrade the site's plan
public var upgradeURL: URL?

public init(timeZone: TimeZone, siteID: Int, api: WordPressComRestApi) {
self.init(timeZone: timeZone, siteID: siteID, service: StatsService(siteID: siteID, api: api, timeZone: timeZone))
public init(
timeZone: TimeZone,
siteID: Int,
api: WordPressComRestApi,
postLikesStore: (any PostLikesStore)? = nil
) {
self.init(
timeZone: timeZone,
siteID: siteID,
service: StatsService(siteID: siteID, api: api, timeZone: timeZone, postLikesStore: postLikesStore)
)
}

init(timeZone: TimeZone, siteID: Int, service: (any StatsServiceProtocol)) {
Expand All @@ -37,10 +46,10 @@ public struct StatsContext: Sendable {

public static let demo: StatsContext = {
var context = StatsContext(timeZone: .current, siteID: 1, service: MockStatsService())
#if DEBUG
#if DEBUG
context.tracker = MockStatsTracker.shared
context.upgradeURL = URL(string: "https://wordpress.com/pricing/")
#endif
#endif
return context
}()

Expand Down
Loading