From ef26441097fbabc45992b7fc3ce7b36f4765fd2b Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 25 Jul 2025 10:39:50 -0400 Subject: [PATCH 1/9] Add PostAuthorDetailsView --- .../Cards/TopListCardViewModel.swift | 4 +- .../HistoricalData/historical-authors.json | 183 ++++++++++++++++- .../Mocks/RealtimeData/realtime-authors.json | 71 ++++++- .../Screens/PostAuthorDetailsView.swift | 192 ++++++++++++++++++ .../Services/Data/TopListData.swift | 1 + Modules/Sources/JetpackStats/Strings.swift | 8 + .../Views/TopList/TopListItemView.swift | 7 + 7 files changed, 445 insertions(+), 21 deletions(-) create mode 100644 Modules/Sources/JetpackStats/Screens/PostAuthorDetailsView.swift diff --git a/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift b/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift index 2b748695f41b..74cf4b0f35c1 100644 --- a/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift +++ b/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift @@ -45,10 +45,10 @@ final class TopListCardViewModel: ObservableObject, TrafficCardViewModel { self.service = service self.groupedItems = { - var primary = service.supportedItems.filter { + let primary = service.supportedItems.filter { !TopListItemType.secondaryItems.contains($0) } - var secondary = service.supportedItems.filter { + let secondary = service.supportedItems.filter { TopListItemType.secondaryItems.contains($0) } return [primary, secondary] diff --git a/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-authors.json b/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-authors.json index 1c3fb8028164..34b30cea286d 100644 --- a/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-authors.json +++ b/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-authors.json @@ -10,7 +10,42 @@ "visitors": 3500, "bounceRate": 32, "timeOnSite": 260 - } + }, + "posts": [ + { + "title": "The Future of Technology: AI and Machine Learning", + "postID": "101", + "postURL": "https://example.com/tech-ai-ml", + "date": "2024-11-15T00:00:00Z", + "type": "post", + "author": "Alex Johnson", + "metrics": { + "views": 1250 + } + }, + { + "title": "Understanding Climate Change: A Global Perspective", + "postID": "102", + "postURL": "https://example.com/climate-change", + "date": "2024-11-10T00:00:00Z", + "type": "post", + "author": "Alex Johnson", + "metrics": { + "views": 980 + } + }, + { + "title": "The Digital Revolution in Healthcare", + "postID": "103", + "postURL": "https://example.com/digital-healthcare", + "date": "2024-11-05T00:00:00Z", + "type": "post", + "author": "Alex Johnson", + "metrics": { + "views": 856 + } + } + ] }, { "name": "Chloe Zhang", @@ -23,7 +58,31 @@ "visitors": 2940, "bounceRate": 34, "timeOnSite": 245 - } + }, + "posts": [ + { + "title": "Sustainable Living: A Guide to Eco-Friendly Practices", + "postID": "201", + "postURL": "https://example.com/sustainable-living", + "date": "2024-11-14T00:00:00Z", + "type": "post", + "author": "Chloe Zhang", + "metrics": { + "views": 1100 + } + }, + { + "title": "The Rise of Remote Work Culture", + "postID": "202", + "postURL": "https://example.com/remote-work", + "date": "2024-11-08T00:00:00Z", + "type": "post", + "author": "Chloe Zhang", + "metrics": { + "views": 890 + } + } + ] }, { "name": "Jordan Davis", @@ -36,7 +95,31 @@ "visitors": 2660, "bounceRate": 36, "timeOnSite": 235 - } + }, + "posts": [ + { + "title": "Cryptocurrency: The Future of Finance?", + "postID": "301", + "postURL": "https://example.com/cryptocurrency", + "date": "2024-11-12T00:00:00Z", + "type": "post", + "author": "Jordan Davis", + "metrics": { + "views": 950 + } + }, + { + "title": "Mental Health in the Digital Age", + "postID": "302", + "postURL": "https://example.com/mental-health", + "date": "2024-11-06T00:00:00Z", + "type": "post", + "author": "Jordan Davis", + "metrics": { + "views": 780 + } + } + ] }, { "name": "Sofia Rodriguez", @@ -49,7 +132,31 @@ "visitors": 2310, "bounceRate": 38, "timeOnSite": 225 - } + }, + "posts": [ + { + "title": "About Us", + "postID": "401", + "postURL": "https://example.com/about", + "date": null, + "type": "page", + "author": "Sofia Rodriguez", + "metrics": { + "views": 825 + } + }, + { + "title": "The Art of Storytelling in Journalism", + "postID": "402", + "postURL": "https://example.com/storytelling", + "date": "2024-11-09T00:00:00Z", + "type": "post", + "author": "Sofia Rodriguez", + "metrics": { + "views": 670 + } + } + ] }, { "name": "Morgan Smith", @@ -62,7 +169,20 @@ "visitors": 2100, "bounceRate": 40, "timeOnSite": 215 - } + }, + "posts": [ + { + "title": "Breaking News: Major Policy Changes", + "postID": "501", + "postURL": "https://example.com/policy-changes", + "date": "2024-11-13T00:00:00Z", + "type": "post", + "author": "Morgan Smith", + "metrics": { + "views": 750 + } + } + ] }, { "name": "Emma Thompson", @@ -75,7 +195,20 @@ "visitors": 1890, "bounceRate": 42, "timeOnSite": 205 - } + }, + "posts": [ + { + "title": "Editorial: The State of Modern Media", + "postID": "601", + "postURL": "https://example.com/modern-media", + "date": "2024-11-11T00:00:00Z", + "type": "post", + "author": "Emma Thompson", + "metrics": { + "views": 675 + } + } + ] }, { "name": "Riley Martinez", @@ -88,7 +221,20 @@ "visitors": 1680, "bounceRate": 39, "timeOnSite": 220 - } + }, + "posts": [ + { + "title": "Local Community Events This Weekend", + "postID": "701", + "postURL": "https://example.com/local-events", + "date": "2024-11-07T00:00:00Z", + "type": "post", + "author": "Riley Martinez", + "metrics": { + "views": 600 + } + } + ] }, { "name": "Jamie Lee", @@ -101,7 +247,20 @@ "visitors": 1470, "bounceRate": 44, "timeOnSite": 195 - } + }, + "posts": [ + { + "title": "Homepage", + "postID": "800", + "postURL": "https://example.com/", + "date": null, + "type": "homepage", + "author": "Jamie Lee", + "metrics": { + "views": 525 + } + } + ] }, { "name": "Avery Taylor", @@ -114,7 +273,8 @@ "visitors": 1295, "bounceRate": 46, "timeOnSite": 185 - } + }, + "posts": [] }, { "name": "Quinn Anderson", @@ -127,6 +287,7 @@ "visitors": 1120, "bounceRate": 48, "timeOnSite": 175 - } + }, + "posts": [] } -] +] \ No newline at end of file diff --git a/Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-authors.json b/Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-authors.json index 4bb8ce07f16e..7558af154644 100644 --- a/Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-authors.json +++ b/Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-authors.json @@ -7,7 +7,31 @@ "views": 240, "comments": 85, "likes": 180 - } + }, + "posts": [ + { + "title": "Breaking: Major Tech Announcement", + "postID": "1001", + "postURL": "https://example.com/breaking-tech", + "date": "2024-11-25T00:00:00Z", + "type": "post", + "author": "Alex Johnson", + "metrics": { + "views": 120 + } + }, + { + "title": "Live Blog: Conference Updates", + "postID": "1002", + "postURL": "https://example.com/live-conference", + "date": "2024-11-25T00:00:00Z", + "type": "post", + "author": "Alex Johnson", + "metrics": { + "views": 95 + } + } + ] }, { "name": "Sam Williams", @@ -17,7 +41,20 @@ "views": 190, "comments": 72, "likes": 145 - } + }, + "posts": [ + { + "title": "Today's Market Analysis", + "postID": "2001", + "postURL": "https://example.com/market-analysis", + "date": "2024-11-25T00:00:00Z", + "type": "post", + "author": "Sam Williams", + "metrics": { + "views": 85 + } + } + ] }, { "name": "Jordan Chen", @@ -27,7 +64,20 @@ "views": 155, "comments": 58, "likes": 120 - } + }, + "posts": [ + { + "title": "Contact Us", + "postID": "3001", + "postURL": "https://example.com/contact", + "date": null, + "type": "page", + "author": "Jordan Chen", + "metrics": { + "views": 70 + } + } + ] }, { "name": "Taylor Davis", @@ -37,7 +87,8 @@ "views": 130, "comments": 45, "likes": 95 - } + }, + "posts": [] }, { "name": "Morgan Smith", @@ -47,7 +98,8 @@ "views": 105, "comments": 38, "likes": 82 - } + }, + "posts": [] }, { "name": "Casey Brown", @@ -57,7 +109,8 @@ "views": 85, "comments": 30, "likes": 65 - } + }, + "posts": [] }, { "name": "Riley Martinez", @@ -67,7 +120,8 @@ "views": 70, "comments": 25, "likes": 55 - } + }, + "posts": [] }, { "name": "Jamie Lee", @@ -77,6 +131,7 @@ "views": 60, "comments": 20, "likes": 45 - } + }, + "posts": [] } ] \ No newline at end of file diff --git a/Modules/Sources/JetpackStats/Screens/PostAuthorDetailsView.swift b/Modules/Sources/JetpackStats/Screens/PostAuthorDetailsView.swift new file mode 100644 index 000000000000..7a24a2d64315 --- /dev/null +++ b/Modules/Sources/JetpackStats/Screens/PostAuthorDetailsView.swift @@ -0,0 +1,192 @@ +import SwiftUI +import WordPressKit + +struct PostAuthorDetailsView: View { + let author: TopListData.Author + + @State private var dateRange: StatsDateRange + + @Environment(\.context) private var context + @ScaledMetric private var avatarSize = 80 + + init(author: TopListData.Author, initialDateRange: StatsDateRange? = nil) { + self.author = author + let calendar = Calendar.current + self._dateRange = State(initialValue: initialDateRange ?? calendar.makeDateRange(for: .last30Days)) + } + + var body: some View { + ScrollView { + VStack(spacing: Constants.step2) { + // Author header + authorHeader + .padding(.horizontal, Constants.step2) + .padding(.top, Constants.step2) + + // Posts list + if let posts = author.posts, !posts.isEmpty { + postsSection(posts: posts) + .padding(.horizontal, Constants.step2) + } else { + emptyPostsView + .padding(.horizontal, Constants.step2) + } + } + .padding(.bottom, Constants.step2) + } + .navigationTitle(Strings.AuthorDetails.title) + .navigationBarTitleDisplayMode(.inline) + .safeAreaInset(edge: .bottom) { + LegacyFloatingDateControl(dateRange: $dateRange) + } + } + + private var authorHeader: some View { + VStack(spacing: Constants.step1) { + // Avatar + AsyncImage(url: author.avatarURL) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { + Image(systemName: "person.circle.fill") + .foregroundColor(.secondary) + } + .frame(width: avatarSize, height: avatarSize) + .clipShape(Circle()) + + // Name and role + VStack(spacing: 4) { + Text(author.name) + .font(.title3) + .fontWeight(.semibold) + + if let role = author.role { + Text(role) + .font(.subheadline) + .foregroundColor(.secondary) + } + } + + // Metrics summary + HStack(spacing: Constants.step3) { + metricSummaryItem( + value: author.metrics.views ?? 0, + label: SiteMetric.views.localizedTitle + ) + + if let comments = author.metrics.comments { + metricSummaryItem( + value: comments, + label: SiteMetric.comments.localizedTitle + ) + } + + if let likes = author.metrics.likes { + metricSummaryItem( + value: likes, + label: SiteMetric.likes.localizedTitle + ) + } + } + .padding(.top, Constants.step1) + } + .frame(maxWidth: .infinity) + .padding(Constants.step2) + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + + private func metricSummaryItem(value: Int, label: String) -> some View { + VStack(spacing: 4) { + Text(StatsValueFormatter.formatNumber(value, onlyLarge: true)) + .font(.headline) + .fontWeight(.semibold) + + Text(label) + .font(.caption) + .foregroundColor(.secondary) + } + } + + private func postsSection(posts: [TopListData.Post]) -> some View { + VStack(alignment: .leading, spacing: Constants.step1) { + Text(Strings.AuthorDetails.posts) + .font(.headline) + + let maxViews = posts.compactMap { $0.metrics.views }.max() ?? 0 + let topListData = TopListChartData( + item: .postsAndPages, + metric: .views, + items: posts, + previousItems: [:], + maxValue: maxViews + ) + + TopListItemsView( + data: topListData, + itemLimit: 10, + dateRange: dateRange, + showDetails: true + ) + } + } + + + private var emptyPostsView: some View { + VStack(spacing: Constants.step1) { + Image(systemName: "doc.text") + .font(.largeTitle) + .foregroundColor(.secondary) + + Text(Strings.AuthorDetails.noPosts) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.vertical, Constants.step4) + .padding(.horizontal, Constants.step2) + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } +} + +#Preview { + NavigationStack { + PostAuthorDetailsView( + author: TopListData.Author( + name: "Alex Johnson", + userId: "1", + role: "Editor-in-Chief", + metrics: SiteMetricsSet( + views: 5000, + likes: 850, + comments: 280 + ), + avatarURL: nil, + posts: [ + TopListData.Post( + title: "The Future of Technology: AI and Machine Learning", + postID: "1", + postURL: URL(string: "https://example.com/post1"), + date: Date(), + type: "post", + author: "Alex Johnson", + metrics: SiteMetricsSet(views: 1250) + ), + TopListData.Post( + title: "Understanding Climate Change", + postID: "2", + postURL: URL(string: "https://example.com/post2"), + date: Date(), + type: "post", + author: "Alex Johnson", + metrics: SiteMetricsSet(views: 980) + ) + ] + ) + ) + } + .environment(\.context, StatsContext.demo) +} diff --git a/Modules/Sources/JetpackStats/Services/Data/TopListData.swift b/Modules/Sources/JetpackStats/Services/Data/TopListData.swift index 947191e6be56..946507f42a15 100644 --- a/Modules/Sources/JetpackStats/Services/Data/TopListData.swift +++ b/Modules/Sources/JetpackStats/Services/Data/TopListData.swift @@ -65,6 +65,7 @@ extension TopListData { let role: String? var metrics: SiteMetricsSet var avatarURL: URL? + var posts: [Post]? var id: TopListItemID { TopListItemID(type: .authors, id: userId) diff --git a/Modules/Sources/JetpackStats/Strings.swift b/Modules/Sources/JetpackStats/Strings.swift index 7fbb3a99c9ed..cb932b1ca86d 100644 --- a/Modules/Sources/JetpackStats/Strings.swift +++ b/Modules/Sources/JetpackStats/Strings.swift @@ -163,4 +163,12 @@ enum Strings { static let dailyAverage = AppLocalizedString("jetpackStats.postDetails.dailyAverage", value: "Daily Average", comment: "Label for daily average in tooltip") static let weekOverWeek = AppLocalizedString("jetpackStats.postDetails.weekOverWeek", value: "Week over Week", comment: "Label for week-over-week comparison in tooltip") } + + enum AuthorDetails { + static let title = AppLocalizedString("jetpackStats.authorDetails.title", value: "Author Details", comment: "Title for the author details screen") + static let periodFormat = AppLocalizedString("jetpackStats.authorDetails.periodFormat", value: "Data for %1$@", comment: "Shows the period for which author data is displayed. %1$@ is the date range.") + static let periodLabel = AppLocalizedString("jetpackStats.authorDetails.period", value: "Period", comment: "Label for the period selector") + static let posts = AppLocalizedString("jetpackStats.authorDetails.posts", value: "Top Posts", comment: "Section title for author's posts") + static let noPosts = AppLocalizedString("jetpackStats.authorDetails.noPosts", value: "No posts found for this period", comment: "Message shown when author has no posts for selected period") + } } diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift index e8cc32e30a3c..e2f0edd9eb14 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift @@ -83,6 +83,8 @@ private extension TopListItemView { return true case is TopListData.ArchiveItem: return true + case is TopListData.Author: + return true default: return false } @@ -99,6 +101,11 @@ private extension TopListItemView { if let url = URL(string: archiveItem.href) { router.openURL(url) } + case let author as TopListData.Author: + let detailsView = PostAuthorDetailsView(author: author, initialDateRange: dateRange) + .environment(\.context, context) + .environment(\.router, router) + router.navigate(to: detailsView) default: break } From 280cbed3d5c78b50ef4e37fb8d0774ca52afaa36 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 25 Jul 2025 11:14:28 -0400 Subject: [PATCH 2/9] Integrate TopListCardViewModel in PostAuthorDetailsView --- .../Cards/TopListCardViewModel.swift | 10 +++-- .../historical-postsAndPages.json | 19 +++++++- .../Screens/PostAuthorDetailsView.swift | 45 ++++++++++++++++--- .../Services/Mocks/MockStatsService.swift | 6 ++- .../JetpackStats/Services/StatsService.swift | 17 +++---- .../Services/StatsServiceProtocol.swift | 2 +- Modules/Sources/JetpackStats/Strings.swift | 2 - .../Views/TopList/TopListItemView.swift | 2 +- 8 files changed, 80 insertions(+), 23 deletions(-) diff --git a/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift b/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift index 74cf4b0f35c1..f091c979de73 100644 --- a/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift +++ b/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift @@ -20,6 +20,7 @@ final class TopListCardViewModel: ObservableObject, TrafficCardViewModel { @Published private(set) var isStale = false private let service: any StatsServiceProtocol + private let fetchLimit: Int private var loadingTask: Task? private var loadRequestCount = 0 @@ -38,11 +39,12 @@ final class TopListCardViewModel: ObservableObject, TrafficCardViewModel { private var isFirstAppear = true - init(selection: Selection, dateRange: StatsDateRange, service: any StatsServiceProtocol) { + init(selection: Selection, dateRange: StatsDateRange, service: any StatsServiceProtocol, fetchLimit: Int = 20) { self.items = service.supportedItems self.selection = selection self.dateRange = dateRange self.service = service + self.fetchLimit = fetchLimit self.groupedItems = { let primary = service.supportedItems.filter { @@ -129,7 +131,8 @@ final class TopListCardViewModel: ObservableObject, TrafficCardViewModel { selection.item, metric: selection.metric, interval: dateRange.dateInterval, - granularity: granularity + granularity: granularity, + limit: fetchLimit ) // Fetch previous data only for items that support it @@ -139,7 +142,8 @@ final class TopListCardViewModel: ObservableObject, TrafficCardViewModel { selection.item, metric: selection.metric, interval: dateRange.effectiveComparisonInterval, - granularity: granularity + granularity: granularity, + limit: fetchLimit ) }() diff --git a/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-postsAndPages.json b/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-postsAndPages.json index 878dfd026de1..82391299e5f8 100644 --- a/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-postsAndPages.json +++ b/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-postsAndPages.json @@ -4,6 +4,7 @@ "postID": "1", "type": "post", "author": "Alex Johnson", + "date": "2024-11-20T00:00:00Z", "metrics": { "views": 5800, "comments": 289, @@ -18,6 +19,7 @@ "postID": "2", "type": "post", "author": "Alex Johnson", + "date": "2024-11-18T00:00:00Z", "metrics": { "views": 2500, "comments": 125, @@ -32,6 +34,7 @@ "postID": "3", "type": "post", "author": "Chloe Zhang", + "date": "2024-11-15T00:00:00Z", "metrics": { "views": 2000, "comments": 98, @@ -46,6 +49,7 @@ "postID": "4", "type": "post", "author": "Jordan Davis", + "date": "2024-11-12T00:00:00Z", "metrics": { "views": 1800, "comments": 85, @@ -60,6 +64,7 @@ "postID": "5", "type": "post", "author": "Sofia Rodriguez", + "date": "2024-11-10T00:00:00Z", "metrics": { "views": 1600, "comments": 72, @@ -74,6 +79,7 @@ "postID": "6", "type": "post", "author": "Morgan Smith", + "date": "2024-11-08T00:00:00Z", "metrics": { "views": 1400, "comments": 65, @@ -88,6 +94,7 @@ "postID": "7", "type": "post", "author": "Emma Thompson", + "date": "2024-11-05T00:00:00Z", "metrics": { "views": 1200, "comments": 58, @@ -102,6 +109,7 @@ "postID": "8", "type": "post", "author": "Riley Martinez", + "date": "2024-11-03T00:00:00Z", "metrics": { "views": 1100, "comments": 52, @@ -116,6 +124,7 @@ "postID": "9", "type": "post", "author": "Riley Martinez", + "date": "2024-10-30T00:00:00Z", "metrics": { "views": 1000, "comments": 48, @@ -130,6 +139,7 @@ "postID": "9991", "type": "page", "author": "Alex Johnson", + "date": null, "metrics": { "views": 900, "comments": 0, @@ -144,6 +154,7 @@ "postID": "10", "type": "post", "author": "Jamie Lee", + "date": "2024-10-28T00:00:00Z", "metrics": { "views": 900, "comments": 42, @@ -158,6 +169,7 @@ "postID": "11", "type": "post", "author": "Sofia Rodriguez", + "date": "2024-10-25T00:00:00Z", "metrics": { "views": 850, "comments": 38, @@ -172,6 +184,7 @@ "postID": "12", "type": "post", "author": "Avery Taylor", + "date": "2024-10-22T00:00:00Z", "metrics": { "views": 750, "comments": 32, @@ -186,6 +199,7 @@ "postID": "9992", "type": "page", "author": "Chloe Zhang", + "date": null, "metrics": { "views": 500, "comments": 0, @@ -200,6 +214,7 @@ "postID": "9993", "type": "page", "author": "Jordan Davis", + "date": null, "metrics": { "views": 400, "comments": 0, @@ -214,6 +229,7 @@ "postID": "9994", "type": "page", "author": "Sofia Rodriguez", + "date": null, "metrics": { "views": 250, "comments": 0, @@ -228,6 +244,7 @@ "postID": "9995", "type": "page", "author": "Morgan Smith", + "date": null, "metrics": { "views": 180, "comments": 0, @@ -237,4 +254,4 @@ "timeOnSite": 55 } } -] +] \ No newline at end of file diff --git a/Modules/Sources/JetpackStats/Screens/PostAuthorDetailsView.swift b/Modules/Sources/JetpackStats/Screens/PostAuthorDetailsView.swift index 7a24a2d64315..8d04e560da8c 100644 --- a/Modules/Sources/JetpackStats/Screens/PostAuthorDetailsView.swift +++ b/Modules/Sources/JetpackStats/Screens/PostAuthorDetailsView.swift @@ -5,17 +5,28 @@ struct PostAuthorDetailsView: View { let author: TopListData.Author @State private var dateRange: StatsDateRange + @StateObject private var viewModel: TopListCardViewModel @Environment(\.context) private var context @ScaledMetric private var avatarSize = 80 - init(author: TopListData.Author, initialDateRange: StatsDateRange? = nil) { + init(author: TopListData.Author, initialDateRange: StatsDateRange? = nil, context: StatsContext) { self.author = author let calendar = Calendar.current - self._dateRange = State(initialValue: initialDateRange ?? calendar.makeDateRange(for: .last30Days)) + let range = initialDateRange ?? calendar.makeDateRange(for: .last30Days) + self._dateRange = State(initialValue: range) + + self._viewModel = StateObject(wrappedValue: TopListCardViewModel( + selection: .init(item: .authors, metric: .views), + dateRange: range, + service: context.service, + fetchLimit: 100 + )) } var body: some View { + let authorPosts = extractAuthorPosts() + ScrollView { VStack(spacing: Constants.step2) { // Author header @@ -24,9 +35,12 @@ struct PostAuthorDetailsView: View { .padding(.top, Constants.step2) // Posts list - if let posts = author.posts, !posts.isEmpty { - postsSection(posts: posts) + if !authorPosts.isEmpty { + postsSection(posts: authorPosts) .padding(.horizontal, Constants.step2) + } else if viewModel.isLoading { + ProgressView() + .padding(.vertical, Constants.step4) } else { emptyPostsView .padding(.horizontal, Constants.step2) @@ -34,6 +48,12 @@ struct PostAuthorDetailsView: View { } .padding(.bottom, Constants.step2) } + .onAppear { + viewModel.onAppear() + } + .onChange(of: dateRange) { newRange in + viewModel.dateRange = newRange + } .navigationTitle(Strings.AuthorDetails.title) .navigationBarTitleDisplayMode(.inline) .safeAreaInset(edge: .bottom) { @@ -41,6 +61,20 @@ struct PostAuthorDetailsView: View { } } + private func extractAuthorPosts() -> [TopListData.Post] { + guard let data = viewModel.matchedData else { + return [] + } + + // Find the current author in the fetched data + if let fetchedAuthor = data.items.compactMap({ $0 as? TopListData.Author }).first(where: { $0.userId == author.userId }), + let posts = fetchedAuthor.posts { + return posts + } else { + return [] + } + } + private var authorHeader: some View { VStack(spacing: Constants.step1) { // Avatar @@ -185,7 +219,8 @@ struct PostAuthorDetailsView: View { metrics: SiteMetricsSet(views: 980) ) ] - ) + ), + context: StatsContext.demo ) } .environment(\.context, StatsContext.demo) diff --git a/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift b/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift index 81c644453cad..11b0d2ef2a9b 100644 --- a/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift +++ b/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift @@ -69,7 +69,7 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { return SiteMetricsData(total: total, metrics: output) } - func getTopListData(_ item: TopListItemType, metric: SiteMetric, interval: DateInterval, granularity: DateRangeGranularity) async throws -> TopListData { + func getTopListData(_ item: TopListItemType, metric: SiteMetric, interval: DateInterval, granularity: DateRangeGranularity, limit: Int?) async throws -> TopListData { await generateDataIfNeeded() guard let typeData = dailyTopListData[item] else { @@ -109,7 +109,7 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { try? await Task.sleep(for: .milliseconds(Int.random(in: 200...500))) - return TopListData(items: Array(sortedItems.prefix(20))) + return TopListData(items: Array(sortedItems.prefix(limit ?? Int.max))) } func getRealtimeTopListData(_ dataType: TopListItemType) async throws -> TopListData { @@ -212,6 +212,7 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { do { let data = try Data(contentsOf: url) let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 // Decode based on data type switch dataType { @@ -336,6 +337,7 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { do { let data = try Data(contentsOf: url) let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 // Decode based on data type switch dataType { diff --git a/Modules/Sources/JetpackStats/Services/StatsService.swift b/Modules/Sources/JetpackStats/Services/StatsService.swift index c2c81bfcfc65..517f73611721 100644 --- a/Modules/Sources/JetpackStats/Services/StatsService.swift +++ b/Modules/Sources/JetpackStats/Services/StatsService.swift @@ -82,8 +82,8 @@ actor StatsService: StatsServiceProtocol { 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)) - async let dailyResponseTask: WordPressKit.StatsSiteMetricsResponse = service.getData(interval: interval, unit: .init(.day)) + 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) @@ -91,14 +91,14 @@ actor StatsService: StatsServiceProtocol { data.total = mapSiteMetricsResponse(dailyResponse).total return data } else { - let response: WordPressKit.StatsSiteMetricsResponse = try await service.getData(interval: interval, unit: .init(granularity)) + let response: WordPressKit.StatsSiteMetricsResponse = try await service.getData(interval: interval, unit: .init(granularity), limit: 0) return mapSiteMetricsResponse(response) } } - func getTopListData(_ item: TopListItemType, metric: SiteMetric, interval: DateInterval, granularity: DateRangeGranularity) async throws -> TopListData { + func getTopListData(_ item: TopListItemType, metric: SiteMetric, interval: DateInterval, granularity: DateRangeGranularity, limit: Int?) async throws -> TopListData { do { - return try await _getTopListData(item, metric: metric, interval: interval, granularity: granularity) + return try await _getTopListData(item, metric: metric, interval: interval, granularity: granularity, limit: limit) } catch { // A workaround for an issue where `/stats` return `"summary": null` // when there are no recoreded periods (happens when the entire requested @@ -111,7 +111,7 @@ actor StatsService: StatsServiceProtocol { } } - private func _getTopListData(_ item: TopListItemType, metric: SiteMetric, interval: DateInterval, granularity: DateRangeGranularity) async throws -> TopListData { + private func _getTopListData(_ item: TopListItemType, metric: SiteMetric, interval: DateInterval, granularity: DateRangeGranularity, limit: Int?) async throws -> TopListData { func getData( _ type: T.Type, @@ -119,7 +119,7 @@ actor StatsService: StatsServiceProtocol { ) 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, parameters: parameters) + return try await service.getData(interval: interval, unit: .day, summarize: true, limit: limit ?? 10) } switch item { @@ -537,11 +537,12 @@ private extension WordPressKit.StatsServiceRemoteV2 { 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: 0, summarize: summarize, parameters: parameters) { (data: TimeStatsType?, error: Error?) in + getData(for: unit, unit: unit, startDate: interval.start, endingOn: interval.end, limit: limit, summarize: summarize, parameters: parameters) { (data: TimeStatsType?, error: Error?) in if let error { continuation.resume(throwing: error) } else if let data { diff --git a/Modules/Sources/JetpackStats/Services/StatsServiceProtocol.swift b/Modules/Sources/JetpackStats/Services/StatsServiceProtocol.swift index 8f307a90df1a..abdb84fb2f88 100644 --- a/Modules/Sources/JetpackStats/Services/StatsServiceProtocol.swift +++ b/Modules/Sources/JetpackStats/Services/StatsServiceProtocol.swift @@ -8,7 +8,7 @@ protocol StatsServiceProtocol: AnyObject, Sendable { func getSupportedMetrics(for item: TopListItemType) -> [SiteMetric] func getSiteStats(interval: DateInterval, granularity: DateRangeGranularity) async throws -> SiteMetricsData - func getTopListData(_ item: TopListItemType, metric: SiteMetric, interval: DateInterval, granularity: DateRangeGranularity) async throws -> TopListData + func getTopListData(_ item: TopListItemType, metric: SiteMetric, interval: DateInterval, granularity: DateRangeGranularity, limit: Int?) async throws -> TopListData func getRealtimeTopListData(_ item: TopListItemType) async throws -> TopListData func getPostDetails(for postID: Int) async throws -> StatsPostDetails func getPostLikes(for postID: Int, count: Int) async throws -> PostLikesData diff --git a/Modules/Sources/JetpackStats/Strings.swift b/Modules/Sources/JetpackStats/Strings.swift index cb932b1ca86d..177432fb9952 100644 --- a/Modules/Sources/JetpackStats/Strings.swift +++ b/Modules/Sources/JetpackStats/Strings.swift @@ -166,8 +166,6 @@ enum Strings { enum AuthorDetails { static let title = AppLocalizedString("jetpackStats.authorDetails.title", value: "Author Details", comment: "Title for the author details screen") - static let periodFormat = AppLocalizedString("jetpackStats.authorDetails.periodFormat", value: "Data for %1$@", comment: "Shows the period for which author data is displayed. %1$@ is the date range.") - static let periodLabel = AppLocalizedString("jetpackStats.authorDetails.period", value: "Period", comment: "Label for the period selector") static let posts = AppLocalizedString("jetpackStats.authorDetails.posts", value: "Top Posts", comment: "Section title for author's posts") static let noPosts = AppLocalizedString("jetpackStats.authorDetails.noPosts", value: "No posts found for this period", comment: "Message shown when author has no posts for selected period") } diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift index e2f0edd9eb14..e2d1a3f47431 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift @@ -102,7 +102,7 @@ private extension TopListItemView { router.openURL(url) } case let author as TopListData.Author: - let detailsView = PostAuthorDetailsView(author: author, initialDateRange: dateRange) + let detailsView = PostAuthorDetailsView(author: author, initialDateRange: dateRange, context: context) .environment(\.context, context) .environment(\.router, router) router.navigate(to: detailsView) From 3ee17b5c60116f2a3969f6486e0f8ca07466204c Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 25 Jul 2025 11:18:17 -0400 Subject: [PATCH 3/9] Add randomization for autor top posts --- .../Services/Mocks/MockStatsService.swift | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift b/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift index 11b0d2ef2a9b..72687ef17973 100644 --- a/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift +++ b/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift @@ -538,7 +538,35 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { // Apply mutations to each item for this day let dailyItems = baseItems.map { item in - mutateItemMetrics(item, growthFactor: growthFactor, seasonalFactor: seasonalFactor, weekendFactor: weekendFactor, randomFactor: randomFactor) + var mutatedItem = mutateItemMetrics(item, growthFactor: growthFactor, seasonalFactor: seasonalFactor, weekendFactor: weekendFactor, randomFactor: randomFactor) + + // If it's an Author with posts, mutate the posts too + if let author = mutatedItem as? TopListData.Author, let posts = author.posts { + var mutatedAuthor = author + mutatedAuthor.posts = posts.map { post in + var mutatedPost = post + // Apply similar mutation factors to post metrics + let postRandomFactor = Double.random(in: 0.9...1.1) // Slight variation per post + let postCombinedFactor = growthFactor * seasonalFactor * weekendFactor * randomFactor * postRandomFactor + + if let views = post.metrics.views { + mutatedPost.metrics.views = Int(Double(views) * postCombinedFactor) + } + if let comments = post.metrics.comments { + mutatedPost.metrics.comments = Int(Double(comments) * postCombinedFactor * 0.8) + } + if let likes = post.metrics.likes { + mutatedPost.metrics.likes = Int(Double(likes) * postCombinedFactor * 0.9) + } + if let visitors = post.metrics.visitors { + mutatedPost.metrics.visitors = Int(Double(visitors) * postCombinedFactor) + } + return mutatedPost + } + mutatedItem = mutatedAuthor + } + + return mutatedItem } let startOfDay = calendar.startOfDay(for: currentDate) From 46e917c6eaac185f2e59b13ca4d69f4760f0b505 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 25 Jul 2025 11:25:00 -0400 Subject: [PATCH 4/9] Use cardStyle --- .../Screens/PostAuthorDetailsView.swift | 118 +++++++----------- .../Sources/JetpackStats/StatsRouter.swift | 21 +++- 2 files changed, 67 insertions(+), 72 deletions(-) diff --git a/Modules/Sources/JetpackStats/Screens/PostAuthorDetailsView.swift b/Modules/Sources/JetpackStats/Screens/PostAuthorDetailsView.swift index 8d04e560da8c..87ee85de90b5 100644 --- a/Modules/Sources/JetpackStats/Screens/PostAuthorDetailsView.swift +++ b/Modules/Sources/JetpackStats/Screens/PostAuthorDetailsView.swift @@ -1,5 +1,6 @@ import SwiftUI import WordPressKit +import DesignSystem struct PostAuthorDetailsView: View { let author: TopListData.Author @@ -31,23 +32,25 @@ struct PostAuthorDetailsView: View { VStack(spacing: Constants.step2) { // Author header authorHeader - .padding(.horizontal, Constants.step2) - .padding(.top, Constants.step2) + .cardStyle() // Posts list if !authorPosts.isEmpty { postsSection(posts: authorPosts) - .padding(.horizontal, Constants.step2) + .cardStyle() } else if viewModel.isLoading { ProgressView() .padding(.vertical, Constants.step4) + .frame(maxWidth: .infinity) + .cardStyle() } else { emptyPostsView - .padding(.horizontal, Constants.step2) + .cardStyle() } } - .padding(.bottom, Constants.step2) + .padding(.vertical, Constants.step1) } + .background(Constants.Colors.background) .onAppear { viewModel.onAppear() } @@ -76,77 +79,55 @@ struct PostAuthorDetailsView: View { } private var authorHeader: some View { - VStack(spacing: Constants.step1) { - // Avatar - AsyncImage(url: author.avatarURL) { image in - image - .resizable() - .aspectRatio(contentMode: .fill) - } placeholder: { - Image(systemName: "person.circle.fill") - .foregroundColor(.secondary) - } - .frame(width: avatarSize, height: avatarSize) - .clipShape(Circle()) - - // Name and role - VStack(spacing: 4) { - Text(author.name) - .font(.title3) - .fontWeight(.semibold) - - if let role = author.role { - Text(role) - .font(.subheadline) + VStack(spacing: Constants.step2) { + HStack(spacing: Constants.step2) { + // Avatar + AsyncImage(url: author.avatarURL) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { + Image(systemName: "person.circle.fill") .foregroundColor(.secondary) } - } - - // Metrics summary - HStack(spacing: Constants.step3) { - metricSummaryItem( - value: author.metrics.views ?? 0, - label: SiteMetric.views.localizedTitle - ) + .frame(width: avatarSize, height: avatarSize) + .clipShape(Circle()) - if let comments = author.metrics.comments { - metricSummaryItem( - value: comments, - label: SiteMetric.comments.localizedTitle - ) + // Name and views + VStack(alignment: .leading, spacing: 4) { + Text(author.name) + .font(.title3) + .fontWeight(.semibold) + + // Views with trend + HStack(spacing: 6) { + HStack(spacing: 2) { + Image(systemName: SiteMetric.views.systemImage) + .font(.caption2.weight(.medium)) + .foregroundColor(.secondary) + + Text(SiteMetric.views.localizedTitle.uppercased()) + .font(.caption.weight(.medium)) + .foregroundColor(.secondary) + } + + Text(StatsValueFormatter.formatNumber(author.metrics.views ?? 0, onlyLarge: true)) + .font(Font.make(.recoleta, textStyle: .title2, weight: .medium)) + .foregroundColor(.primary) + } } - if let likes = author.metrics.likes { - metricSummaryItem( - value: likes, - label: SiteMetric.likes.localizedTitle - ) - } + Spacer() } - .padding(.top, Constants.step1) } - .frame(maxWidth: .infinity) + .frame(maxWidth: .infinity, alignment: .leading) .padding(Constants.step2) - .background(Color(.secondarySystemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 12)) } - private func metricSummaryItem(value: Int, label: String) -> some View { - VStack(spacing: 4) { - Text(StatsValueFormatter.formatNumber(value, onlyLarge: true)) - .font(.headline) - .fontWeight(.semibold) - - Text(label) - .font(.caption) - .foregroundColor(.secondary) - } - } private func postsSection(posts: [TopListData.Post]) -> some View { - VStack(alignment: .leading, spacing: Constants.step1) { - Text(Strings.AuthorDetails.posts) - .font(.headline) + VStack(alignment: .leading, spacing: Constants.step2) { + StatsCardTitleView(title: Strings.AuthorDetails.posts) let maxViews = posts.compactMap { $0.metrics.views }.max() ?? 0 let topListData = TopListChartData( @@ -164,6 +145,7 @@ struct PostAuthorDetailsView: View { showDetails: true ) } + .padding(Constants.step2) } @@ -181,8 +163,6 @@ struct PostAuthorDetailsView: View { .frame(maxWidth: .infinity) .padding(.vertical, Constants.step4) .padding(.horizontal, Constants.step2) - .background(Color(.secondarySystemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 12)) } } @@ -192,11 +172,9 @@ struct PostAuthorDetailsView: View { author: TopListData.Author( name: "Alex Johnson", userId: "1", - role: "Editor-in-Chief", + role: nil, metrics: SiteMetricsSet( - views: 5000, - likes: 850, - comments: 280 + views: 5000 ), avatarURL: nil, posts: [ diff --git a/Modules/Sources/JetpackStats/StatsRouter.swift b/Modules/Sources/JetpackStats/StatsRouter.swift index d9a50dba1690..c343a7049e37 100644 --- a/Modules/Sources/JetpackStats/StatsRouter.swift +++ b/Modules/Sources/JetpackStats/StatsRouter.swift @@ -11,7 +11,8 @@ public protocol StatsRouterScreenFactory: AnyObject { public final class StatsRouter: @unchecked Sendable { @MainActor var navigationController: UINavigationController? { - (viewController as? UINavigationController) ?? viewController?.navigationController + let vc = viewController ?? findTopViewController() + return (vc as? UINavigationController) ?? vc?.navigationController } public weak var viewController: UIViewController? @@ -22,6 +23,21 @@ public final class StatsRouter: @unchecked Sendable { self.viewController = viewController self.factory = factory } + + @MainActor + private func findTopViewController() -> UIViewController? { + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first(where: { $0.isKeyWindow }) else { + return nil + } + + var topController = window.rootViewController + while let presented = topController?.presentedViewController { + topController = presented + } + + return topController + } @MainActor func navigate(to view: Content) { @@ -45,7 +61,8 @@ public final class StatsRouter: @unchecked Sendable { func openURL(_ url: URL) { // Open URL in in-app Safari let safariViewController = SFSafariViewController(url: url) - viewController?.present(safariViewController, animated: true) + let vc = viewController ?? findTopViewController() + vc?.present(safariViewController, animated: true) } } From c02cbcd79a2faaa905396e7654632f4425f5f6d7 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 25 Jul 2025 11:37:32 -0400 Subject: [PATCH 5/9] Rename AuthorStatsView --- .../{PostAuthorDetailsView.swift => AuthorStatsView.swift} | 4 ++-- .../Sources/JetpackStats/Views/TopList/TopListItemView.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename Modules/Sources/JetpackStats/Screens/{PostAuthorDetailsView.swift => AuthorStatsView.swift} (99%) diff --git a/Modules/Sources/JetpackStats/Screens/PostAuthorDetailsView.swift b/Modules/Sources/JetpackStats/Screens/AuthorStatsView.swift similarity index 99% rename from Modules/Sources/JetpackStats/Screens/PostAuthorDetailsView.swift rename to Modules/Sources/JetpackStats/Screens/AuthorStatsView.swift index 87ee85de90b5..7acc84992499 100644 --- a/Modules/Sources/JetpackStats/Screens/PostAuthorDetailsView.swift +++ b/Modules/Sources/JetpackStats/Screens/AuthorStatsView.swift @@ -2,7 +2,7 @@ import SwiftUI import WordPressKit import DesignSystem -struct PostAuthorDetailsView: View { +struct AuthorStatsView: View { let author: TopListData.Author @State private var dateRange: StatsDateRange @@ -168,7 +168,7 @@ struct PostAuthorDetailsView: View { #Preview { NavigationStack { - PostAuthorDetailsView( + AuthorStatsView( author: TopListData.Author( name: "Alex Johnson", userId: "1", diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift index e2d1a3f47431..ddf65b9b002c 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift @@ -102,7 +102,7 @@ private extension TopListItemView { router.openURL(url) } case let author as TopListData.Author: - let detailsView = PostAuthorDetailsView(author: author, initialDateRange: dateRange, context: context) + let detailsView = AuthorStatsView(author: author, initialDateRange: dateRange, context: context) .environment(\.context, context) .environment(\.router, router) router.navigate(to: detailsView) From c86cf6bfeb0616bf4f30845b070a96beffcde9cc Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 25 Jul 2025 12:35:56 -0400 Subject: [PATCH 6/9] Refactor --- .../JetpackStats/Cards/TopListCard.swift | 44 ++-- .../Cards/TopListCardViewModel.swift | 64 ++++-- .../Screens/AuthorStatsView.swift | 212 ++++++++---------- .../Services/Mocks/MockStatsService.swift | 6 +- .../Sources/JetpackStats/StatsRouter.swift | 15 +- Modules/Sources/JetpackStats/Strings.swift | 4 +- .../WeeklyTrendsViewModelTests.swift | 23 -- 7 files changed, 187 insertions(+), 181 deletions(-) diff --git a/Modules/Sources/JetpackStats/Cards/TopListCard.swift b/Modules/Sources/JetpackStats/Cards/TopListCard.swift index b81f93d43e80..f84e2842d27f 100644 --- a/Modules/Sources/JetpackStats/Cards/TopListCard.swift +++ b/Modules/Sources/JetpackStats/Cards/TopListCard.swift @@ -36,31 +36,37 @@ struct TopListCard: View { private var headerView: some View { HStack { - Menu { - ForEach(Array(viewModel.groupedItems.enumerated()), id: \.offset) { _, items in - Section { - ForEach(items) { item in - Button { - var selection = viewModel.selection - selection.item = item - - let supportedMetric = getSupportedMetrics(for: item) - if !supportedMetric.contains(selection.metric), - let metric = supportedMetric.first { - selection.metric = metric + if viewModel.items.count > 1 { + Menu { + ForEach(Array(viewModel.groupedItems.enumerated()), id: \.offset) { _, items in + Section { + ForEach(items) { item in + Button { + var selection = viewModel.selection + selection.item = item + + let supportedMetric = getSupportedMetrics(for: item) + if !supportedMetric.contains(selection.metric), + let metric = supportedMetric.first { + selection.metric = metric + } + viewModel.selection = selection + } label: { + Label(item.localizedTitle, systemImage: item.systemImage) } - viewModel.selection = selection - } label: { - Label(item.localizedTitle, systemImage: item.systemImage) } } } + .tint(Color.primary) + } label: { + InlineValuePickerTitle(title: viewModel.selection.item.localizedTitle) } - .tint(Color.primary) - } label: { - InlineValuePickerTitle(title: viewModel.selection.item.localizedTitle) + .fixedSize() + } else { + Text(viewModel.selection.item.localizedTitle) + .font(.subheadline) + .fontWeight(.medium) } - .fixedSize() Spacer() diff --git a/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift b/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift index f091c979de73..5bcfaf2defb3 100644 --- a/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift +++ b/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift @@ -21,6 +21,7 @@ final class TopListCardViewModel: ObservableObject, TrafficCardViewModel { private let service: any StatsServiceProtocol private let fetchLimit: Int + private let filter: Filter? private var loadingTask: Task? private var loadRequestCount = 0 @@ -30,21 +31,33 @@ final class TopListCardViewModel: ObservableObject, TrafficCardViewModel { didSet { loadData() } } - struct Selection: Equatable { + struct Selection: Equatable, Sendable { var item: TopListItemType var metric: SiteMetric } + enum Filter: Equatable { + case author(userId: String) + } + var isFirstLoad: Bool { isLoading && matchedData == nil } private var isFirstAppear = true - init(selection: Selection, dateRange: StatsDateRange, service: any StatsServiceProtocol, fetchLimit: Int = 20) { - self.items = service.supportedItems + init( + selection: Selection, + dateRange: StatsDateRange, + service: any StatsServiceProtocol, + items: [TopListItemType]? = nil, + fetchLimit: Int = 20, + filter: Filter? = nil + ) { + self.items = items ?? service.supportedItems self.selection = selection self.dateRange = dateRange self.service = service self.fetchLimit = fetchLimit + self.filter = filter self.groupedItems = { let primary = service.supportedItems.filter { @@ -126,9 +139,18 @@ final class TopListCardViewModel: ObservableObject, TrafficCardViewModel { private func getTopListData(for selection: Selection, dateRange: StatsDateRange) async throws -> TopListChartData { let granularity = dateRange.dateInterval.preferredGranularity + // When filter is set for author, we need to fetch authors data + let fetchItem: TopListItemType + if let filter, case .author = filter { + // We have to fake it as "Posts & Pages" does not support filtering + fetchItem = .authors + } else { + fetchItem = selection.item + } + // Fetch current data async let currentTask = service.getTopListData( - selection.item, + fetchItem, metric: selection.metric, interval: dateRange.dateInterval, granularity: granularity, @@ -139,7 +161,7 @@ final class TopListCardViewModel: ObservableObject, TrafficCardViewModel { async let previousTask: TopListData? = { guard selection.item != .archive else { return nil } return try await service.getTopListData( - selection.item, + fetchItem, metric: selection.metric, interval: dateRange.effectiveComparisonInterval, granularity: granularity, @@ -149,26 +171,42 @@ final class TopListCardViewModel: ObservableObject, TrafficCardViewModel { let (current, previous) = try await (currentTask, previousTask) + let currentItems = filteredItems(current.items) + let previousItems = filteredItems(previous?.items ?? []) + // Build previous items dictionary var previousItemsDict: [TopListItemID: any TopListItem] = [:] - if let previousItems = previous?.items { - for item in previousItems { - previousItemsDict[item.id] = item - } + for item in previousItems { + previousItemsDict[item.id] = item } - // Calculate max value from current items based on selected metric + // Calculate max value from filtered items based on selected metric let metric = selection.metric - let maxValue = current.items + let maxValue = currentItems .compactMap { $0.metrics[metric] } .max() ?? 1 return TopListChartData( item: selection.item, metric: metric, - items: current.items, + items: currentItems, previousItems: previousItemsDict, maxValue: maxValue ) } -} + + private func filteredItems(_ items: [any TopListItem]) -> [any TopListItem] { + guard let filter else { + return items + } + switch filter { + case .author(let userId): + let authors = items.lazy.compactMap { $0 as? TopListData.Author } + if let author = authors.first(where: { $0.userId == userId }), + let posts = author.posts { + return posts + } + return [] + } + } + } diff --git a/Modules/Sources/JetpackStats/Screens/AuthorStatsView.swift b/Modules/Sources/JetpackStats/Screens/AuthorStatsView.swift index 7acc84992499..9f8e12941bfe 100644 --- a/Modules/Sources/JetpackStats/Screens/AuthorStatsView.swift +++ b/Modules/Sources/JetpackStats/Screens/AuthorStatsView.swift @@ -4,56 +4,43 @@ import DesignSystem struct AuthorStatsView: View { let author: TopListData.Author - + @State private var dateRange: StatsDateRange + @StateObject private var viewModel: TopListCardViewModel - + @Environment(\.context) private var context - @ScaledMetric private var avatarSize = 80 - + + @ScaledMetric private var avatarSize = 60 + init(author: TopListData.Author, initialDateRange: StatsDateRange? = nil, context: StatsContext) { self.author = author let calendar = Calendar.current let range = initialDateRange ?? calendar.makeDateRange(for: .last30Days) self._dateRange = State(initialValue: range) - + self._viewModel = StateObject(wrappedValue: TopListCardViewModel( - selection: .init(item: .authors, metric: .views), + selection: .init(item: .postsAndPages, metric: .views), dateRange: range, service: context.service, - fetchLimit: 100 + fetchLimit: 32, + filter: .author(userId: author.userId) )) } - + var body: some View { - let authorPosts = extractAuthorPosts() - ScrollView { VStack(spacing: Constants.step2) { - // Author header - authorHeader + headerView + .cardStyle() + + TopListCard(viewModel: viewModel) .cardStyle() - - // Posts list - if !authorPosts.isEmpty { - postsSection(posts: authorPosts) - .cardStyle() - } else if viewModel.isLoading { - ProgressView() - .padding(.vertical, Constants.step4) - .frame(maxWidth: .infinity) - .cardStyle() - } else { - emptyPostsView - .cardStyle() - } } .padding(.vertical, Constants.step1) } .background(Constants.Colors.background) - .onAppear { - viewModel.onAppear() - } + .animation(.spring, value: viewModel.matchedData.map(ObjectIdentifier.init)) .onChange(of: dateRange) { newRange in viewModel.dateRange = newRange } @@ -63,106 +50,101 @@ struct AuthorStatsView: View { LegacyFloatingDateControl(dateRange: $dateRange) } } - - private func extractAuthorPosts() -> [TopListData.Post] { - guard let data = viewModel.matchedData else { - return [] - } - - // Find the current author in the fetched data - if let fetchedAuthor = data.items.compactMap({ $0 as? TopListData.Author }).first(where: { $0.userId == author.userId }), - let posts = fetchedAuthor.posts { - return posts - } else { - return [] - } - } - - private var authorHeader: some View { - VStack(spacing: Constants.step2) { - HStack(spacing: Constants.step2) { + + private var headerView: some View { + VStack(spacing: Constants.step3) { + HStack(spacing: Constants.step3) { // Avatar - AsyncImage(url: author.avatarURL) { image in - image - .resizable() - .aspectRatio(contentMode: .fill) - } placeholder: { - Image(systemName: "person.circle.fill") - .foregroundColor(.secondary) - } - .frame(width: avatarSize, height: avatarSize) - .clipShape(Circle()) - - // Name and views - VStack(alignment: .leading, spacing: 4) { + AvatarView( + name: author.name, + imageURL: author.avatarURL, + size: avatarSize + ) + + // Name and metrics + VStack(alignment: .leading, spacing: Constants.step1) { Text(author.name) .font(.title3) .fontWeight(.semibold) - - // Views with trend - HStack(spacing: 6) { - HStack(spacing: 2) { - Image(systemName: SiteMetric.views.systemImage) - .font(.caption2.weight(.medium)) - .foregroundColor(.secondary) - - Text(SiteMetric.views.localizedTitle.uppercased()) - .font(.caption.weight(.medium)) - .foregroundColor(.secondary) - } - - Text(StatsValueFormatter.formatNumber(author.metrics.views ?? 0, onlyLarge: true)) - .font(Font.make(.recoleta, textStyle: .title2, weight: .medium)) - .foregroundColor(.primary) + .foregroundColor(.primary) + + // Views for period + if let data = calculatePeriodViews() { + makeViewsView(current: data.current, previous: data.previous) + } else { + makeViewsView(current: 1000, previous: 500) + .redacted(reason: .placeholder) } } - + Spacer() } } .frame(maxWidth: .infinity, alignment: .leading) - .padding(Constants.step2) + .padding(Constants.step3) } - - - private func postsSection(posts: [TopListData.Post]) -> some View { - VStack(alignment: .leading, spacing: Constants.step2) { - StatsCardTitleView(title: Strings.AuthorDetails.posts) - - let maxViews = posts.compactMap { $0.metrics.views }.max() ?? 0 - let topListData = TopListChartData( - item: .postsAndPages, - metric: .views, - items: posts, - previousItems: [:], - maxValue: maxViews - ) - - TopListItemsView( - data: topListData, - itemLimit: 10, - dateRange: dateRange, - showDetails: true - ) + + private func makeViewsView(current: Int, previous: Int?) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 4) { + Image(systemName: SiteMetric.views.systemImage) + .font(.caption.weight(.medium)) + .foregroundColor(.secondary) + + Text(SiteMetric.views.localizedTitle) + .font(.caption.weight(.medium)) + .foregroundColor(.secondary) + .textCase(.uppercase) + } + + HStack(spacing: Constants.step2) { + Text(StatsValueFormatter.formatNumber(current, onlyLarge: true)) + .font(Font.make(.recoleta, textStyle: .title2, weight: .medium)) + .foregroundColor(.primary) + .contentTransition(.numericText()) + + // Trend badge + if let previous { + let trend = TrendViewModel( + currentValue: current, + previousValue: previous, + metric: .views + ) + + HStack(spacing: 4) { + Image(systemName: trend.systemImage) + .font(.caption2.weight(.semibold)) + Text(trend.formattedPercentage) + .font(.caption.weight(.medium)) + .contentTransition(.numericText()) + } + .foregroundColor(trend.sentiment.foregroundColor) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(trend.sentiment.backgroundColor) + .clipShape(Capsule()) + } + } } - .padding(Constants.step2) } - - - private var emptyPostsView: some View { - VStack(spacing: Constants.step1) { - Image(systemName: "doc.text") - .font(.largeTitle) - .foregroundColor(.secondary) - - Text(Strings.AuthorDetails.noPosts) - .font(.subheadline) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) + + private func calculatePeriodViews() -> (current: Int, previous: Int?)? { + guard let data = viewModel.matchedData else { return nil } + + // Sum up views from all posts in the current period + let currentViews = data.items.compactMap { item in + (item as? TopListData.Post)?.metrics.views + }.reduce(0, +) + + // Calculate previous period views if available + var previousViews: Int? + if !data.previousItems.isEmpty { + previousViews = data.previousItems.values.compactMap { item in + (item as? TopListData.Post)?.metrics.views + }.reduce(0, +) } - .frame(maxWidth: .infinity) - .padding(.vertical, Constants.step4) - .padding(.horizontal, Constants.step2) + + return (current: currentViews, previous: previousViews) } } diff --git a/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift b/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift index 72687ef17973..214c939dae13 100644 --- a/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift +++ b/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift @@ -539,7 +539,7 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { // Apply mutations to each item for this day let dailyItems = baseItems.map { item in var mutatedItem = mutateItemMetrics(item, growthFactor: growthFactor, seasonalFactor: seasonalFactor, weekendFactor: weekendFactor, randomFactor: randomFactor) - + // If it's an Author with posts, mutate the posts too if let author = mutatedItem as? TopListData.Author, let posts = author.posts { var mutatedAuthor = author @@ -548,7 +548,7 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { // Apply similar mutation factors to post metrics let postRandomFactor = Double.random(in: 0.9...1.1) // Slight variation per post let postCombinedFactor = growthFactor * seasonalFactor * weekendFactor * randomFactor * postRandomFactor - + if let views = post.metrics.views { mutatedPost.metrics.views = Int(Double(views) * postCombinedFactor) } @@ -565,7 +565,7 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { } mutatedItem = mutatedAuthor } - + return mutatedItem } diff --git a/Modules/Sources/JetpackStats/StatsRouter.swift b/Modules/Sources/JetpackStats/StatsRouter.swift index c343a7049e37..b1ab126f4760 100644 --- a/Modules/Sources/JetpackStats/StatsRouter.swift +++ b/Modules/Sources/JetpackStats/StatsRouter.swift @@ -23,19 +23,16 @@ public final class StatsRouter: @unchecked Sendable { self.viewController = viewController self.factory = factory } - + @MainActor private func findTopViewController() -> UIViewController? { - guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first(where: { $0.isKeyWindow }) else { + guard let window = UIApplication.shared.mainWindow else { return nil } - var topController = window.rootViewController while let presented = topController?.presentedViewController { topController = presented } - return topController } @@ -66,6 +63,14 @@ public final class StatsRouter: @unchecked Sendable { } } +private extension UIApplication { + @objc var mainWindow: UIWindow? { + connectedScenes + .compactMap { ($0 as? UIWindowScene)?.keyWindow } + .first + } +} + class MockStatsRouterScreenFactory: StatsRouterScreenFactory { func makeCommentsListViewController(siteID: Int, postID: Int) -> UIViewController { UIHostingController(rootView: Text(Strings.Errors.generic)) diff --git a/Modules/Sources/JetpackStats/Strings.swift b/Modules/Sources/JetpackStats/Strings.swift index 177432fb9952..094a52cd8cae 100644 --- a/Modules/Sources/JetpackStats/Strings.swift +++ b/Modules/Sources/JetpackStats/Strings.swift @@ -165,8 +165,6 @@ enum Strings { } enum AuthorDetails { - static let title = AppLocalizedString("jetpackStats.authorDetails.title", value: "Author Details", comment: "Title for the author details screen") - static let posts = AppLocalizedString("jetpackStats.authorDetails.posts", value: "Top Posts", comment: "Section title for author's posts") - static let noPosts = AppLocalizedString("jetpackStats.authorDetails.noPosts", value: "No posts found for this period", comment: "Message shown when author has no posts for selected period") + static let title = AppLocalizedString("jetpackStats.authorDetails.title", value: "Author Stats", comment: "Title for the author details screen") } } diff --git a/Modules/Tests/JetpackStatsTests/WeeklyTrendsViewModelTests.swift b/Modules/Tests/JetpackStatsTests/WeeklyTrendsViewModelTests.swift index 11742f7a6a92..0b560159e68e 100644 --- a/Modules/Tests/JetpackStatsTests/WeeklyTrendsViewModelTests.swift +++ b/Modules/Tests/JetpackStatsTests/WeeklyTrendsViewModelTests.swift @@ -363,29 +363,6 @@ struct WeeklyTrendsViewModelTests { #expect(week.averagePerDay == expectedAverage) } - @Test("Calculates average per day for average metric") - func averagePerDayForAverageMetric() { - // Given - let dataPoints = [ - DataPoint(date: Date("2025-01-05T00:00:00Z"), value: 100), - DataPoint(date: Date("2025-01-06T00:00:00Z"), value: 200), - DataPoint(date: Date("2025-01-07T00:00:00Z"), value: 300) - ] - - // When - let viewModel = WeeklyTrendsViewModel( - dataPoints: dataPoints, - calendar: calendar, - metric: .timeOnSite // Average metric - ) - - // Then - #expect(viewModel.weeks.count == 1) - let week = viewModel.weeks[0] - let expectedAverage = (100 + 200 + 300) / 3 // For average metrics, it's the average value - #expect(week.averagePerDay == expectedAverage) - } - @Test("Handles empty week for average calculation") func averagePerDayWithEmptyWeek() { // Given From 0b23787f50d05895b6067fae53a7299bc2587751 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 25 Jul 2025 12:42:30 -0400 Subject: [PATCH 7/9] Add separator overlay --- Modules/Sources/JetpackStats/Screens/AuthorStatsView.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Modules/Sources/JetpackStats/Screens/AuthorStatsView.swift b/Modules/Sources/JetpackStats/Screens/AuthorStatsView.swift index 9f8e12941bfe..88d44f1a2f83 100644 --- a/Modules/Sources/JetpackStats/Screens/AuthorStatsView.swift +++ b/Modules/Sources/JetpackStats/Screens/AuthorStatsView.swift @@ -60,6 +60,10 @@ struct AuthorStatsView: View { imageURL: author.avatarURL, size: avatarSize ) + .overlay( + Circle() + .stroke(Color(.opaqueSeparator), lineWidth: 1) + ) // Name and metrics VStack(alignment: .leading, spacing: Constants.step1) { From d9ec864ba01761a4cee3cd3740a451e5c8afbe00 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 25 Jul 2025 13:02:26 -0400 Subject: [PATCH 8/9] Add missing mapping --- .../JetpackStats/Services/StatsService.swift | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/Modules/Sources/JetpackStats/Services/StatsService.swift b/Modules/Sources/JetpackStats/Services/StatsService.swift index 517f73611721..7bfc8ad9abff 100644 --- a/Modules/Sources/JetpackStats/Services/StatsService.swift +++ b/Modules/Sources/JetpackStats/Services/StatsService.swift @@ -313,14 +313,13 @@ actor StatsService: StatsServiceProtocol { return SiteMetricsData(total: total, metrics: metrics) } - private func mapPostsToTopListData(_ data: StatsTopPostsTimeIntervalData, filterKind: StatsTopPost.Kind? = nil) -> TopListData { + private func mapPostsToTopListData(_ data: StatsTopPostsTimeIntervalData) -> TopListData { let dateFormatter = DateFormatter() dateFormatter.locale = Locale(identifier: "en_US_POSIX") dateFormatter.timeZone = siteTimeZone dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" - let posts = filterKind != nil ? data.topPosts.filter { $0.kind == filterKind } : data.topPosts - let items = posts.map { post in + let items = data.topPosts.map { post in TopListData.Post( title: post.title, postID: String(post.postID), @@ -360,16 +359,31 @@ actor StatsService: StatsServiceProtocol { } private func mapAuthorsToTopListData(_ data: StatsTopAuthorsTimeIntervalData) -> TopListData { + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.timeZone = siteTimeZone + dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + let items = data.topAuthors.map { author in TopListData.Author( name: author.name, userId: author.name, // NOTE: WordPressKit doesn't provide user ID role: nil, metrics: SiteMetricsSet(views: author.viewsCount), - avatarURL: author.iconURL + avatarURL: author.iconURL, + posts: author.posts.map { post in + TopListData.Post( + title: post.title, + postID: String(post.postID), + postURL: post.postURL, + date: post.date.flatMap(dateFormatter.date), + type: post.kind.description, + author: nil, + metrics: SiteMetricsSet(views: post.viewsCount) + ) + } ) } - return TopListData(items: items) } From def20a720e7f64d4c195fced06902db5822716c9 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 25 Jul 2025 13:18:29 -0400 Subject: [PATCH 9/9] Refactor WordPressKit->JetpackStats mapping in StatsService --- .../Data/TopListData+WordPressKit.swift | 134 +++++++++++ .../JetpackStats/Services/StatsService.swift | 213 +++--------------- 2 files changed, 163 insertions(+), 184 deletions(-) create mode 100644 Modules/Sources/JetpackStats/Services/Data/TopListData+WordPressKit.swift diff --git a/Modules/Sources/JetpackStats/Services/Data/TopListData+WordPressKit.swift b/Modules/Sources/JetpackStats/Services/Data/TopListData+WordPressKit.swift new file mode 100644 index 000000000000..e86c34d5b3ca --- /dev/null +++ b/Modules/Sources/JetpackStats/Services/Data/TopListData+WordPressKit.swift @@ -0,0 +1,134 @@ +import Foundation +import WordPressKit + +extension TopListData.Post { + init(_ post: WordPressKit.StatsTopPost, dateFormatter: DateFormatter) { + self.init( + title: post.title, + postID: String(post.postID), + postURL: post.postURL, + date: post.date.flatMap(dateFormatter.date), + type: post.kind.description, + author: nil, + metrics: SiteMetricsSet(views: post.viewsCount) + ) + } +} + +extension TopListData.Referrer { + init(_ referrer: WordPressKit.StatsReferrer) { + self.init( + name: referrer.title, + domain: referrer.url?.host, + metrics: SiteMetricsSet(views: referrer.viewsCount) + ) + } +} + +extension TopListData.Location { + init(_ country: WordPressKit.StatsCountry) { + self.init( + country: country.name, + flag: Self.countryCodeToEmoji(country.code), + countryCode: country.code, + metrics: SiteMetricsSet(views: country.viewsCount) + ) + } + + private static func countryCodeToEmoji(_ code: String) -> String? { + let base: UInt32 = 127397 + var scalarView = String.UnicodeScalarView() + for i in code.uppercased().unicodeScalars { + guard let scalar = UnicodeScalar(base + i.value) else { return nil } + scalarView.append(scalar) + } + return String(scalarView) + } +} + +extension TopListData.Author { + init(_ author: WordPressKit.StatsTopAuthor, dateFormatter: DateFormatter) { + self.init( + name: author.name, + userId: author.name, // NOTE: WordPressKit doesn't provide user ID + role: nil, + metrics: SiteMetricsSet(views: author.viewsCount), + avatarURL: author.iconURL, + posts: author.posts.map { TopListData.Post($0, dateFormatter: dateFormatter) } + ) + } +} + +extension TopListData.ExternalLink { + init(_ click: WordPressKit.StatsClick) { + self.init( + url: click.clickedURL?.absoluteString ?? "", + title: click.title, + metrics: SiteMetricsSet(views: click.clicksCount) + ) + } +} + +extension TopListData.FileDownload { + init(_ download: WordPressKit.StatsFileDownload) { + self.init( + fileName: URL(string: download.file)?.lastPathComponent ?? download.file, + filePath: download.file, + metrics: SiteMetricsSet(downloads: download.downloadCount) + ) + } +} + +extension TopListData.SearchTerm { + init(_ searchTerm: WordPressKit.StatsSearchTerm) { + self.init( + term: searchTerm.term, + metrics: SiteMetricsSet(views: searchTerm.viewsCount) + ) + } +} + +extension TopListData.Video { + init(_ video: WordPressKit.StatsVideo) { + self.init( + title: video.title, + postId: String(video.postID), + videoUrl: video.videoURL, + metrics: SiteMetricsSet(views: video.playsCount) + ) + } +} + +extension TopListData.ArchiveItem { + init(_ item: WordPressKit.StatsArchiveItem) { + self.init( + href: item.href, + value: item.value, + metrics: SiteMetricsSet(views: item.views) + ) + } +} + +extension TopListData.ArchiveSection { + init(sectionName: String, items: [WordPressKit.StatsArchiveItem]) { + let archiveItems = items.map { TopListData.ArchiveItem($0) } + let totalViews = items.reduce(0) { $0 + $1.views } + + self.init( + sectionName: sectionName, + items: archiveItems, + metrics: SiteMetricsSet(views: totalViews) + ) + } +} + +private extension StatsTopPost.Kind { + var description: String { + switch self { + case .post: "post" + case .page: "page" + case .homepage: "homepage" + case .unknown: "unknown" + } + } +} diff --git a/Modules/Sources/JetpackStats/Services/StatsService.swift b/Modules/Sources/JetpackStats/Services/StatsService.swift index 7bfc8ad9abff..d55c5d58a323 100644 --- a/Modules/Sources/JetpackStats/Services/StatsService.swift +++ b/Modules/Sources/JetpackStats/Services/StatsService.swift @@ -127,7 +127,10 @@ actor StatsService: StatsServiceProtocol { switch metric { case .views: let data = try await getData(StatsTopPostsTimeIntervalData.self, parameters: ["skip_archives": "1"]) - return mapPostsToTopListData(data) + let dateFormatter = makeHourlyDateFormatter() + return TopListData(items: data.topPosts.map { + TopListData.Post($0, dateFormatter: dateFormatter) + }) case .comments: fatalError() default: @@ -136,21 +139,24 @@ actor StatsService: StatsServiceProtocol { case .referrers: let data = try await getData(StatsTopReferrersTimeIntervalData.self) - return mapReferrersToTopListData(data) + return TopListData(items: data.referrers.map(TopListData.Referrer.init)) case .locations: let data = try await getData(StatsTopCountryTimeIntervalData.self) - return mapCountriesToTopListData(data) + return TopListData(items: data.countries.map(TopListData.Location.init)) case .authors: let data = try await getData(StatsTopAuthorsTimeIntervalData.self) - return mapAuthorsToTopListData(data) + let dateFormatter = makeHourlyDateFormatter() + return TopListData(items: data.topAuthors.map { + TopListData.Author($0, dateFormatter: dateFormatter) + }) case .externalLinks: switch metric { case .views: let data = try await getData(StatsTopClicksTimeIntervalData.self) - return mapClicksToTopListData(data) + return TopListData(items: data.clicks.map(TopListData.ExternalLink.init)) default: throw StatsServiceError.unavailable } @@ -159,7 +165,7 @@ actor StatsService: StatsServiceProtocol { switch metric { case .downloads: let data = try await getData(StatsFileDownloadsTimeIntervalData.self) - return mapFileDownloadsToTopListData(data) + return TopListData(items: data.fileDownloads.map(TopListData.FileDownload.init)) default: throw StatsServiceError.unavailable } @@ -168,7 +174,7 @@ actor StatsService: StatsServiceProtocol { switch metric { case .views: let data = try await getData(StatsSearchTermTimeIntervalData.self) - return mapSearchTermsToTopListData(data) + return TopListData(items: data.searchTerms.map(TopListData.SearchTerm.init)) default: throw StatsServiceError.unavailable } @@ -177,7 +183,7 @@ actor StatsService: StatsServiceProtocol { switch metric { case .views: let data = try await getData(StatsTopVideosTimeIntervalData.self) - return mapVideosToTopListData(data) + return TopListData(items: data.videos.map(TopListData.Video.init)) default: throw StatsServiceError.unavailable } @@ -186,7 +192,13 @@ actor StatsService: StatsServiceProtocol { switch metric { case .views: let data = try await getData(StatsArchiveTimeIntervalData.self) - return mapArchiveToTopListData(data) + let sections = data.summary.compactMap { (sectionName, items) -> TopListData.ArchiveSection? in + guard !items.isEmpty else { return nil } + return TopListData.ArchiveSection(sectionName: sectionName, items: items) + } + // Sort sections by total views + let sortedSections = sections.sorted { ($0.metrics.views ?? 0) > ($1.metrics.views ?? 0) } + return TopListData(items: sortedSections) default: throw StatsServiceError.unavailable } @@ -275,6 +287,14 @@ actor StatsService: StatsServiceProtocol { // MARK: - Mapping (WordPressKit -> JetpackStats) + private func makeHourlyDateFormatter() -> DateFormatter { + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.timeZone = siteTimeZone + dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + return dateFormatter + } + private func mapSiteMetricsResponse(_ response: WordPressKit.StatsSiteMetricsResponse) -> SiteMetricsData { var calendar = Calendar.current calendar.timeZone = siteTimeZone @@ -312,170 +332,6 @@ actor StatsService: StatsServiceProtocol { } return SiteMetricsData(total: total, metrics: metrics) } - - private func mapPostsToTopListData(_ data: StatsTopPostsTimeIntervalData) -> TopListData { - let dateFormatter = DateFormatter() - dateFormatter.locale = Locale(identifier: "en_US_POSIX") - dateFormatter.timeZone = siteTimeZone - dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" - - let items = data.topPosts.map { post in - TopListData.Post( - title: post.title, - postID: String(post.postID), - postURL: post.postURL, - date: post.date.flatMap(dateFormatter.date), - type: post.kind.description, - author: nil, - metrics: SiteMetricsSet(views: post.viewsCount) - ) - } - return TopListData(items: items) - } - - private func mapReferrersToTopListData(_ data: StatsTopReferrersTimeIntervalData) -> TopListData { - let items = data.referrers.map { referrer in - TopListData.Referrer( - name: referrer.title, - domain: referrer.url?.host, - metrics: SiteMetricsSet(views: referrer.viewsCount) - ) - } - - return TopListData(items: items) - } - - private func mapCountriesToTopListData(_ data: StatsTopCountryTimeIntervalData) -> TopListData { - let items = data.countries.map { country in - TopListData.Location( - country: country.name, - flag: countryCodeToEmoji(country.code), - countryCode: country.code, - metrics: SiteMetricsSet(views: country.viewsCount) - ) - } - - return TopListData(items: items) - } - - private func mapAuthorsToTopListData(_ data: StatsTopAuthorsTimeIntervalData) -> TopListData { - let dateFormatter = DateFormatter() - dateFormatter.locale = Locale(identifier: "en_US_POSIX") - dateFormatter.timeZone = siteTimeZone - dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" - - let items = data.topAuthors.map { author in - TopListData.Author( - name: author.name, - userId: author.name, // NOTE: WordPressKit doesn't provide user ID - role: nil, - metrics: SiteMetricsSet(views: author.viewsCount), - avatarURL: author.iconURL, - posts: author.posts.map { post in - TopListData.Post( - title: post.title, - postID: String(post.postID), - postURL: post.postURL, - date: post.date.flatMap(dateFormatter.date), - type: post.kind.description, - author: nil, - metrics: SiteMetricsSet(views: post.viewsCount) - ) - } - ) - } - return TopListData(items: items) - } - - private func countryCodeToEmoji(_ code: String) -> String? { - let base: UInt32 = 127397 - var scalarView = String.UnicodeScalarView() - for i in code.uppercased().unicodeScalars { - guard let scalar = UnicodeScalar(base + i.value) else { return nil } - scalarView.append(scalar) - } - return String(scalarView) - } - - private func mapClicksToTopListData(_ data: StatsTopClicksTimeIntervalData) -> TopListData { - let items = data.clicks.map { click in - TopListData.ExternalLink( - url: click.clickedURL?.absoluteString ?? "", - title: click.title, - metrics: SiteMetricsSet( - views: click.clicksCount - ) - ) - } - return TopListData(items: items) - } - - private func mapFileDownloadsToTopListData(_ data: StatsFileDownloadsTimeIntervalData) -> TopListData { - let items = data.fileDownloads.map { download in - TopListData.FileDownload( - fileName: URL(string: download.file)?.lastPathComponent ?? download.file, - filePath: download.file, - metrics: SiteMetricsSet(downloads: download.downloadCount) - ) - } - return TopListData(items: items) - } - - private func mapSearchTermsToTopListData(_ data: StatsSearchTermTimeIntervalData) -> TopListData { - let items = data.searchTerms.map { searchTerm in - TopListData.SearchTerm( - term: searchTerm.term, - metrics: SiteMetricsSet( - views: searchTerm.viewsCount - ) - ) - } - return TopListData(items: items) - } - - private func mapVideosToTopListData(_ data: StatsTopVideosTimeIntervalData) -> TopListData { - let items = data.videos.map { video in - TopListData.Video( - title: video.title, - postId: String(video.postID), - videoUrl: video.videoURL, - metrics: SiteMetricsSet( - views: video.playsCount - ) - ) - } - return TopListData(items: items) - } - - private func mapArchiveToTopListData(_ data: StatsArchiveTimeIntervalData) -> TopListData { - // Convert the summary dictionary into archive sections - let sections = data.summary.compactMap { (sectionName, items) -> TopListData.ArchiveSection? in - guard !items.isEmpty else { return nil } - - // Map archive items - let archiveItems = items.map { item in - TopListData.ArchiveItem( - href: item.href, - value: item.value, - metrics: SiteMetricsSet(views: item.views) - ) - } - - // Calculate total views for the section - let totalViews = items.reduce(0) { $0 + $1.views } - - return TopListData.ArchiveSection( - sectionName: sectionName, - items: archiveItems, - metrics: SiteMetricsSet(views: totalViews) - ) - } - - // Sort sections by total views - let sortedSections = sections.sorted { ($0.metrics.views ?? 0) > ($1.metrics.views ?? 0) } - - return TopListData(items: sortedSections) - } } enum StatsServiceError: LocalizedError { @@ -520,17 +376,6 @@ private extension WordPressKit.StatsPeriodUnit { } } -private extension StatsTopPost.Kind { - var description: String { - switch self { - case .post: "post" - case .page: "page" - case .homepage: "homepage" - case .unknown: "unknown" - } - } -} - private extension WordPressKit.StatsSiteMetricsResponse.Metric { init?(_ metric: SiteMetric) { switch metric {