From 742aa85cc158b15429fd9bd965d6bf18f59e4cb5 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Wed, 28 Jan 2026 16:17:10 -0500 Subject: [PATCH 1/2] Add UTM card to Stats --- .../JetpackStats/Analytics/StatsEvent.swift | 10 + .../JetpackStats/Cards/TopListCard.swift | 55 +- .../JetpackStats/Cards/TopListViewModel.swift | 41 +- .../Mocks/HistoricalData/historical-utm.json | 602 ++++++++++++++++++ .../Screens/AuthorStatsView.swift | 3 +- .../Screens/UTMMetricStatsView.swift | 211 ++++++ .../Services/Data/TopListData.swift | 49 ++ .../Data/TopListItem+WordPressKit.swift | 11 + .../Services/Data/TopListItem.swift | 15 + .../Services/Data/TopListItemOptions.swift | 9 +- .../Services/Data/TopListItemType.swift | 23 +- .../Services/Data/UTMParamGrouping.swift | 45 ++ .../Extensions/WordPressKit+Extensions.swift | 12 + .../Services/Mocks/MockStatsService.swift | 100 +-- .../JetpackStats/Services/StatsService.swift | 23 +- Modules/Sources/JetpackStats/Strings.swift | 22 + .../Rows/TopListUTMMetricRowView.swift | 23 + .../Views/TopList/TopListItemView.swift | 9 + .../WordPressKit/StatsServiceRemoteV2.swift | 52 ++ .../StatsUTMTimeIntervalData.swift | 129 ++++ RELEASE-NOTES.txt | 1 + .../Mock Data/stats-utm-empty.json | 6 + .../Mock Data/stats-utm-source-medium.json | 75 +++ .../Tests/StatsRemoteV2Tests.swift | 47 ++ .../WPAnalyticsEvent+JetpackStats.swift | 2 + .../Utility/Analytics/WPAnalyticsEvent.swift | 6 + 26 files changed, 1477 insertions(+), 104 deletions(-) create mode 100644 Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-utm.json create mode 100644 Modules/Sources/JetpackStats/Screens/UTMMetricStatsView.swift create mode 100644 Modules/Sources/JetpackStats/Services/Data/UTMParamGrouping.swift create mode 100644 Modules/Sources/JetpackStats/Views/TopList/Rows/TopListUTMMetricRowView.swift create mode 100644 Modules/Sources/WordPressKit/StatsUTMTimeIntervalData.swift create mode 100644 Tests/WordPressKitTests/WordPressKitTests/Mock Data/stats-utm-empty.json create mode 100644 Tests/WordPressKitTests/WordPressKitTests/Mock Data/stats-utm-source-medium.json diff --git a/Modules/Sources/JetpackStats/Analytics/StatsEvent.swift b/Modules/Sources/JetpackStats/Analytics/StatsEvent.swift index a5c8b4c5d102..c2823678fdca 100644 --- a/Modules/Sources/JetpackStats/Analytics/StatsEvent.swift +++ b/Modules/Sources/JetpackStats/Analytics/StatsEvent.swift @@ -47,6 +47,9 @@ public enum StatsEvent { /// Referrer stats screen shown case referrerStatsScreenShown + /// UTM metric stats screen shown + case utmMetricStatsScreenShown + // MARK: - Date Range Events /// Date range preset selected @@ -154,6 +157,12 @@ public enum StatsEvent { /// - "to_breakdown": New breakdown case deviceBreakdownChanged + /// UTM parameter grouping changed + /// - Parameters: + /// - "from_grouping": Previous grouping ("sourceMedium", "campaignSourceMedium", etc.) + /// - "to_grouping": New grouping + case utmParamGroupingChanged + // MARK: - Navigation Events /// Stats tab selected @@ -229,6 +238,7 @@ extension TopListItemType { case .searchTerms: "search_terms" case .fileDownloads: "file_downloads" case .archive: "archive" + case .utm: "utm" } } } diff --git a/Modules/Sources/JetpackStats/Cards/TopListCard.swift b/Modules/Sources/JetpackStats/Cards/TopListCard.swift index 4da29467752d..cb2c806c18e5 100644 --- a/Modules/Sources/JetpackStats/Cards/TopListCard.swift +++ b/Modules/Sources/JetpackStats/Cards/TopListCard.swift @@ -6,6 +6,7 @@ struct TopListCard: View { private let itemLimit: Int private let reserveSpace: Bool private let showMoreInline: Bool + private let showItemTypePicker: Bool @State private var isExpanded = false @@ -16,12 +17,14 @@ struct TopListCard: View { viewModel: TopListViewModel, itemLimit: Int = 5, reserveSpace: Bool = true, - showMoreInline: Bool = false + showMoreInline: Bool = false, + showItemTypePicker: Bool = true ) { self.viewModel = viewModel self.itemLimit = itemLimit self.reserveSpace = reserveSpace self.showMoreInline = showMoreInline + self.showItemTypePicker = showItemTypePicker } var body: some View { @@ -81,10 +84,14 @@ struct TopListCard: View { private var cardHeaderView: some View { HStack { - Menu { - itemTypePicker - } label: { - StatsCardTitleView(title: viewModel.selection.item.localizedTitle, showChevron: true) + if showItemTypePicker { + Menu { + itemTypePicker + } label: { + StatsCardTitleView(title: viewModel.selection.item.localizedTitle, showChevron: true) + } + } else { + StatsCardTitleView(title: viewModel.selection.item.localizedTitle, showChevron: false) } Spacer(minLength: 44) } @@ -114,7 +121,7 @@ struct TopListCard: View { private var listHeaderView: some View { HStack { - // Left side: Location level for locations, device breakdown for devices, otherwise item type + // Left side: Location level for locations, device breakdown for devices, UTM parameter for UTM, otherwise item type if viewModel.selection.item == .locations { Menu { locationLevelPicker @@ -135,6 +142,16 @@ struct TopListCard: View { .padding(.vertical, Constants.step0_5) } .fixedSize() + } else if viewModel.selection.item == .utm { + Menu { + utmParamGroupingPicker + } label: { + InlineValuePickerTitle(title: viewModel.selection.options.utmParamGrouping.localizedTitle) + .foregroundStyle(Color.secondary) + .padding(.top, 6) + .padding(.vertical, Constants.step0_5) + } + .fixedSize() } else { makeColumnTitle(viewModel.selection.item.localizedColumnName) } @@ -267,6 +284,28 @@ struct TopListCard: View { .tint(Color.primary) } + private var utmParamGroupingPicker: some View { + ForEach(Array(UTMParamGrouping.grouped.enumerated()), id: \.offset) { _, groupings in + Section { + ForEach(groupings) { grouping in + Button { + let previousGrouping = viewModel.selection.options.utmParamGrouping + viewModel.selection.options.utmParamGrouping = grouping + + // Track UTM parameter grouping change + viewModel.tracker?.send(.utmParamGroupingChanged, properties: [ + "from_grouping": previousGrouping.analyticsName, + "to_grouping": grouping.analyticsName + ]) + } label: { + Text(grouping.localizedTitle) + } + } + } + } + .tint(Color.primary) + } + @ViewBuilder private var listContentView: some View { Group { @@ -371,8 +410,8 @@ struct TopListCard: View { #Preview { ScrollView { - VStack { - TopListCardPreview(item: .devices) + VStack(spacing: 16) { + TopListCardPreview(item: .utm) TopListCardPreview(item: .authors) TopListCardPreview(item: .locations) } diff --git a/Modules/Sources/JetpackStats/Cards/TopListViewModel.swift b/Modules/Sources/JetpackStats/Cards/TopListViewModel.swift index 3f39e9582e27..93b886b53bee 100644 --- a/Modules/Sources/JetpackStats/Cards/TopListViewModel.swift +++ b/Modules/Sources/JetpackStats/Cards/TopListViewModel.swift @@ -56,6 +56,7 @@ final class TopListViewModel: ObservableObject, TrafficCardViewModel { enum Filter: Equatable { case author(userId: String) + case utmMetric(values: [String]) } var isFirstLoad: Bool { isLoading && data == nil } @@ -83,15 +84,16 @@ final class TopListViewModel: ObservableObject, TrafficCardViewModel { self.data = initialData self.isLoading = initialData == nil - self.groupedItems = { - let primary = service.supportedItems.filter { - !TopListItemType.secondaryItems.contains($0) - } - let secondary = service.supportedItems.filter { - TopListItemType.secondaryItems.contains($0) - } - return [primary, secondary] - }() + let supportedItems = Set(service.supportedItems) + self.groupedItems = [ + TopListItemType.contentItems, + TopListItemType.trafficSourceItems, + TopListItemType.audienceEngagementItems + ].map { + $0.filter(supportedItems.contains) + }.filter { + !$0.isEmpty + } } func updateConfiguration(_ newConfiguration: TopListCardConfiguration) { @@ -209,11 +211,17 @@ final class TopListViewModel: ObservableObject, TrafficCardViewModel { private func getTopListData(for selection: Selection, dateRange: StatsDateRange) async throws -> TopListData { let granularity = dateRange.dateInterval.preferredGranularity - // When filter is set for author, we need to fetch authors data + // When filter is set, we need to fetch the appropriate data type let fetchItem: TopListItemType - if let filter, case .author = filter { - // We have to fake it as "Posts & Pages" does not support filtering - fetchItem = .authors + if let filter { + switch filter { + case .author: + // We have to fake it as "Posts & Pages" does not support filtering + fetchItem = .authors + case .utmMetric: + // Fetch UTM data to get posts for specific campaign + fetchItem = .utm + } } else { fetchItem = selection.item } @@ -277,6 +285,13 @@ final class TopListViewModel: ObservableObject, TrafficCardViewModel { return posts } return [] + case .utmMetric(let values): + let utmMetrics = items.lazy.compactMap { $0 as? TopListItem.UTMMetric } + if let metric = utmMetrics.first(where: { $0.values == values }), + let posts = metric.posts { + return posts + } + return [] } } diff --git a/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-utm.json b/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-utm.json new file mode 100644 index 000000000000..3c68d7c5c8f8 --- /dev/null +++ b/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-utm.json @@ -0,0 +1,602 @@ +[ + { + "label": "google / cpc", + "values": ["google", "cpc"], + "metrics": { + "views": 15200, + "comments": 420, + "likes": 1850, + "visitors": 11400, + "bounceRate": 22, + "timeOnSite": 340 + }, + "posts": [ + { + "title": "Best printer 2025: just buy a Brother laser printer, the winner is clear, middle finger in the air", + "postID": "1", + "postURL": "https://example.com/best-printer-2025", + "date": "2024-11-20T00:00:00Z", + "type": "post", + "metrics": { + "views": 7500 + } + }, + { + "title": "I Replaced Every Component in My Framework Laptop Until It Became a Desktop", + "postID": "2", + "postURL": "https://example.com/framework-laptop-desktop", + "date": "2024-11-18T00:00:00Z", + "type": "post", + "metrics": { + "views": 4200 + } + }, + { + "title": "The M3 MacBook Air Is Faster Than My Personality", + "postID": "20", + "postURL": "https://example.com/m3-macbook-air-review", + "date": "2024-10-02T00:00:00Z", + "type": "post", + "metrics": { + "views": 3500 + } + } + ] + }, + { + "label": "facebook / social", + "values": ["facebook", "social"], + "metrics": { + "views": 12000, + "comments": 580, + "likes": 2100, + "visitors": 8400, + "bounceRate": 28, + "timeOnSite": 310 + }, + "posts": [ + { + "title": "The Cybertruck's Frunk Won't Stop Eating Fingers", + "postID": "3", + "postURL": "https://example.com/cybertruck-frunk-safety", + "date": "2024-11-15T00:00:00Z", + "type": "post", + "metrics": { + "views": 6000 + } + }, + { + "title": "Nothing Phone (2a): Now With 50% More Nothing", + "postID": "6", + "postURL": "https://example.com/nothing-phone-2a", + "date": "2024-11-08T00:00:00Z", + "type": "post", + "metrics": { + "views": 4200 + } + }, + { + "title": "Netflix Password Sharing Crackdown: A Love Story", + "postID": "21", + "postURL": "https://example.com/netflix-password-sharing", + "date": "2024-09-30T00:00:00Z", + "type": "post", + "metrics": { + "views": 1800 + } + } + ] + }, + { + "label": "linkedin / social", + "values": ["linkedin", "social"], + "metrics": { + "views": 8500, + "comments": 320, + "likes": 1200, + "visitors": 6375, + "bounceRate": 30, + "timeOnSite": 290 + }, + "posts": [ + { + "title": "Matter Smart Home Protocol Still Doesn't Matter: A Year Later", + "postID": "4", + "postURL": "https://example.com/matter-smart-home-fail", + "date": "2024-11-12T00:00:00Z", + "type": "post", + "metrics": { + "views": 4500 + } + }, + { + "title": "ChatGPT Convinced Me It Was Sentient (It Was Lying)", + "postID": "15", + "postURL": "https://example.com/chatgpt-sentient", + "date": "2024-10-15T00:00:00Z", + "type": "post", + "metrics": { + "views": 3100 + } + }, + { + "title": "Apple Finally Invented USB-C (They're Calling It Revolutionary)", + "postID": "13", + "postURL": "https://example.com/apple-usb-c", + "date": "2024-10-20T00:00:00Z", + "type": "post", + "metrics": { + "views": 900 + } + } + ] + }, + { + "label": "google / organic", + "values": ["google", "organic"], + "metrics": { + "views": 6700, + "comments": 180, + "likes": 890, + "visitors": 5025, + "bounceRate": 35, + "timeOnSite": 280 + }, + "posts": [ + { + "title": "Trackpad Alignment on the New MacBook Pro: A 10,000 Word Investigation", + "postID": "5", + "postURL": "https://example.com/macbook-trackpad-investigation", + "date": "2024-11-10T00:00:00Z", + "type": "post", + "metrics": { + "views": 3200 + } + }, + { + "title": "Samsung's Moon Photography Is Real* (*Terms and Conditions Apply)", + "postID": "7", + "postURL": "https://example.com/samsung-moon-photography", + "date": "2024-11-05T00:00:00Z", + "type": "post", + "metrics": { + "views": 2400 + } + }, + { + "title": "The Steam Deck OLED Screen Is So Good I Licked It", + "postID": "8", + "postURL": "https://example.com/steam-deck-oled", + "date": "2024-11-03T00:00:00Z", + "type": "post", + "metrics": { + "views": 1100 + } + } + ] + }, + { + "label": "newsletter / email", + "values": ["newsletter", "email"], + "metrics": { + "views": 4500, + "comments": 150, + "likes": 720, + "visitors": 3375, + "bounceRate": 25, + "timeOnSite": 350 + }, + "posts": [ + { + "title": "Meta's VR Legs Update: They're Still Not Real", + "postID": "9", + "postURL": "https://example.com/meta-vr-legs", + "date": "2024-10-30T00:00:00Z", + "type": "post", + "metrics": { + "views": 2800 + } + }, + { + "title": "This Smart Toaster Has DRM and I'm Not Even Surprised Anymore", + "postID": "14", + "postURL": "https://example.com/smart-toaster-drm", + "date": "2024-10-18T00:00:00Z", + "type": "post", + "metrics": { + "views": 1200 + } + }, + { + "title": "Sony's New $200 Controller Has a Screen Nobody Asked For", + "postID": "19", + "postURL": "https://example.com/sony-controller-screen", + "date": "2024-10-05T00:00:00Z", + "type": "post", + "metrics": { + "views": 500 + } + } + ] + }, + { + "label": "twitter / social", + "values": ["twitter", "social"], + "metrics": { + "views": 3200, + "comments": 220, + "likes": 580, + "visitors": 2400, + "bounceRate": 40, + "timeOnSite": 240 + }, + "posts": [ + { + "title": "Microsoft's New AI Can Predict When You'll Rage Quit Excel", + "postID": "10", + "postURL": "https://example.com/microsoft-ai-excel", + "date": "2024-10-28T00:00:00Z", + "type": "post", + "metrics": { + "views": 1800 + } + }, + { + "title": "Twitter's New Logo Is Just the Letter X and Everyone Is Very Normal About It", + "postID": "17", + "postURL": "https://example.com/twitter-x-logo", + "date": "2024-10-10T00:00:00Z", + "type": "post", + "metrics": { + "views": 900 + } + }, + { + "title": "I Tried to Return a Smart TV That Shows Ads. Best Buy Laughed at Me", + "postID": "18", + "postURL": "https://example.com/smart-tv-ads-return", + "date": "2024-10-08T00:00:00Z", + "type": "post", + "metrics": { + "views": 500 + } + } + ] + }, + { + "label": "reddit / social", + "values": ["reddit", "social"], + "metrics": { + "views": 2800, + "comments": 190, + "likes": 510, + "visitors": 2100, + "bounceRate": 38, + "timeOnSite": 265 + }, + "posts": [ + { + "title": "I Shattered the Apple Vision Pro's Front Glass and It Only Cost Me $799 to Fix", + "postID": "11", + "postURL": "https://example.com/vision-pro-repair", + "date": "2024-10-25T00:00:00Z", + "type": "post", + "metrics": { + "views": 1600 + } + }, + { + "title": "Google's Pixel 8 Pro Has Too Many Cameras (I Counted)", + "postID": "12", + "postURL": "https://example.com/pixel-8-pro-cameras", + "date": "2024-10-22T00:00:00Z", + "type": "post", + "metrics": { + "views": 850 + } + }, + { + "title": "The $2000 Dyson Hair Dryer: A Scientific Analysis of My Poor Life Choices", + "postID": "16", + "postURL": "https://example.com/dyson-hair-dryer-review", + "date": "2024-10-12T00:00:00Z", + "type": "post", + "metrics": { + "views": 350 + } + } + ] + }, + { + "label": "spring-sale / google / cpc", + "values": ["spring-sale", "google", "cpc"], + "metrics": { + "views": 5600, + "comments": 140, + "likes": 820, + "visitors": 4200, + "bounceRate": 20, + "timeOnSite": 380 + }, + "posts": [ + { + "title": "The Rabbit R1 Is Just an Android App in a Tiny Orange Box", + "postID": "22", + "postURL": "https://example.com/rabbit-r1-android", + "date": "2024-09-28T00:00:00Z", + "type": "post", + "metrics": { + "views": 5600 + } + } + ] + }, + { + "label": "google", + "values": ["google"], + "metrics": { + "views": 22500, + "comments": 680, + "likes": 2950, + "visitors": 16875, + "bounceRate": 24, + "timeOnSite": 330 + }, + "posts": [ + { + "title": "Best printer 2025: just buy a Brother laser printer, the winner is clear, middle finger in the air", + "postID": "1", + "postURL": "https://example.com/best-printer-2025", + "date": "2024-11-20T00:00:00Z", + "type": "post", + "metrics": { + "views": 12000 + } + }, + { + "title": "Trackpad Alignment on the New MacBook Pro: A 10,000 Word Investigation", + "postID": "5", + "postURL": "https://example.com/macbook-trackpad-investigation", + "date": "2024-11-10T00:00:00Z", + "type": "post", + "metrics": { + "views": 6200 + } + }, + { + "title": "The M3 MacBook Air Is Faster Than My Personality", + "postID": "20", + "postURL": "https://example.com/m3-macbook-air-review", + "date": "2024-10-02T00:00:00Z", + "type": "post", + "metrics": { + "views": 4300 + } + } + ] + }, + { + "label": "facebook", + "values": ["facebook"], + "metrics": { + "views": 12500, + "comments": 620, + "likes": 2250, + "visitors": 9375, + "bounceRate": 27, + "timeOnSite": 315 + }, + "posts": [ + { + "title": "The Cybertruck's Frunk Won't Stop Eating Fingers", + "postID": "3", + "postURL": "https://example.com/cybertruck-frunk-safety", + "date": "2024-11-15T00:00:00Z", + "type": "post", + "metrics": { + "views": 6800 + } + }, + { + "title": "Nothing Phone (2a): Now With 50% More Nothing", + "postID": "6", + "postURL": "https://example.com/nothing-phone-2a", + "date": "2024-11-08T00:00:00Z", + "type": "post", + "metrics": { + "views": 4500 + } + } + ] + }, + { + "label": "cpc", + "values": ["cpc"], + "metrics": { + "views": 20800, + "comments": 560, + "likes": 2670, + "visitors": 15600, + "bounceRate": 21, + "timeOnSite": 360 + }, + "posts": [ + { + "title": "Best printer 2025: just buy a Brother laser printer, the winner is clear, middle finger in the air", + "postID": "1", + "postURL": "https://example.com/best-printer-2025", + "date": "2024-11-20T00:00:00Z", + "type": "post", + "metrics": { + "views": 11200 + } + }, + { + "title": "I Replaced Every Component in My Framework Laptop Until It Became a Desktop", + "postID": "2", + "postURL": "https://example.com/framework-laptop-desktop", + "date": "2024-11-18T00:00:00Z", + "type": "post", + "metrics": { + "views": 5800 + } + }, + { + "title": "The Rabbit R1 Is Just an Android App in a Tiny Orange Box", + "postID": "22", + "postURL": "https://example.com/rabbit-r1-android", + "date": "2024-09-28T00:00:00Z", + "type": "post", + "metrics": { + "views": 3800 + } + } + ] + }, + { + "label": "social", + "values": ["social"], + "metrics": { + "views": 26500, + "comments": 1310, + "likes": 4400, + "visitors": 19875, + "bounceRate": 32, + "timeOnSite": 285 + }, + "posts": [ + { + "title": "The Cybertruck's Frunk Won't Stop Eating Fingers", + "postID": "3", + "postURL": "https://example.com/cybertruck-frunk-safety", + "date": "2024-11-15T00:00:00Z", + "type": "post", + "metrics": { + "views": 8200 + } + }, + { + "title": "Twitter's New Logo Is Just the Letter X and Everyone Is Very Normal About It", + "postID": "17", + "postURL": "https://example.com/twitter-x-logo", + "date": "2024-10-10T00:00:00Z", + "type": "post", + "metrics": { + "views": 7400 + } + }, + { + "title": "Nothing Phone (2a): Now With 50% More Nothing", + "postID": "6", + "postURL": "https://example.com/nothing-phone-2a", + "date": "2024-11-08T00:00:00Z", + "type": "post", + "metrics": { + "views": 5600 + } + }, + { + "title": "Matter Smart Home Protocol Still Doesn't Matter: A Year Later", + "postID": "4", + "postURL": "https://example.com/matter-smart-home-fail", + "date": "2024-11-12T00:00:00Z", + "type": "post", + "metrics": { + "views": 5300 + } + } + ] + }, + { + "label": "email", + "values": ["email"], + "metrics": { + "views": 4500, + "comments": 150, + "likes": 720, + "visitors": 3375, + "bounceRate": 25, + "timeOnSite": 350 + }, + "posts": [ + { + "title": "Meta's VR Legs Update: They're Still Not Real", + "postID": "9", + "postURL": "https://example.com/meta-vr-legs", + "date": "2024-10-30T00:00:00Z", + "type": "post", + "metrics": { + "views": 2800 + } + }, + { + "title": "This Smart Toaster Has DRM and I'm Not Even Surprised Anymore", + "postID": "14", + "postURL": "https://example.com/smart-toaster-drm", + "date": "2024-10-18T00:00:00Z", + "type": "post", + "metrics": { + "views": 1200 + } + } + ] + }, + { + "label": "spring-sale", + "values": ["spring-sale"], + "metrics": { + "views": 5600, + "comments": 140, + "likes": 820, + "visitors": 4200, + "bounceRate": 20, + "timeOnSite": 380 + }, + "posts": [ + { + "title": "The Rabbit R1 Is Just an Android App in a Tiny Orange Box", + "postID": "22", + "postURL": "https://example.com/rabbit-r1-android", + "date": "2024-09-28T00:00:00Z", + "type": "post", + "metrics": { + "views": 5600 + } + } + ] + }, + { + "label": "black-friday / google / cpc", + "values": ["black-friday", "google", "cpc"], + "metrics": { + "views": 8900, + "comments": 280, + "likes": 1450, + "visitors": 6675, + "bounceRate": 18, + "timeOnSite": 395 + }, + "posts": [ + { + "title": "Best printer 2025: just buy a Brother laser printer, the winner is clear, middle finger in the air", + "postID": "1", + "postURL": "https://example.com/best-printer-2025", + "date": "2024-11-20T00:00:00Z", + "type": "post", + "metrics": { + "views": 6200 + } + }, + { + "title": "I Replaced Every Component in My Framework Laptop Until It Became a Desktop", + "postID": "2", + "postURL": "https://example.com/framework-laptop-desktop", + "date": "2024-11-18T00:00:00Z", + "type": "post", + "metrics": { + "views": 2700 + } + } + ] + } +] diff --git a/Modules/Sources/JetpackStats/Screens/AuthorStatsView.swift b/Modules/Sources/JetpackStats/Screens/AuthorStatsView.swift index 0cbb37c92fd2..fd781007fb37 100644 --- a/Modules/Sources/JetpackStats/Screens/AuthorStatsView.swift +++ b/Modules/Sources/JetpackStats/Screens/AuthorStatsView.swift @@ -44,7 +44,8 @@ struct AuthorStatsView: View { viewModel: viewModel, itemLimit: 6, reserveSpace: false, - showMoreInline: true + showMoreInline: true, + showItemTypePicker: false ) } .padding(.vertical, Constants.step1) diff --git a/Modules/Sources/JetpackStats/Screens/UTMMetricStatsView.swift b/Modules/Sources/JetpackStats/Screens/UTMMetricStatsView.swift new file mode 100644 index 000000000000..920b76bfcd4a --- /dev/null +++ b/Modules/Sources/JetpackStats/Screens/UTMMetricStatsView.swift @@ -0,0 +1,211 @@ +import SwiftUI +import DesignSystem +@preconcurrency import WordPressKit + +struct UTMMetricStatsView: View { + let utmMetric: TopListItem.UTMMetric + + @State private var dateRange: StatsDateRange + + @StateObject private var viewModel: TopListViewModel + + @Environment(\.context) private var context + @Environment(\.horizontalSizeClass) var horizontalSizeClass + + init(utmMetric: TopListItem.UTMMetric, initialDateRange: StatsDateRange? = nil, context: StatsContext) { + self.utmMetric = utmMetric + + let range = initialDateRange ?? context.calendar.makeDateRange(for: .last30Days) + self._dateRange = State(initialValue: range) + + let configuration = TopListCardConfiguration( + item: .postsAndPages, + metric: .views + ) + self._viewModel = StateObject(wrappedValue: TopListViewModel( + configuration: configuration, + dateRange: range, + service: context.service, + tracker: context.tracker, + items: [.postsAndPages], + filter: .utmMetric(values: utmMetric.values) + )) + } + + var body: some View { + ScrollView { + VStack(spacing: Constants.step3) { + headerView + .cardStyle() + + TopListCard( + viewModel: viewModel, + itemLimit: 6, + reserveSpace: false, + showMoreInline: true, + showItemTypePicker: false + ) + } + .padding(.vertical, Constants.step1) + .padding(.horizontal, Constants.cardHorizontalInset(for: horizontalSizeClass)) + .frame(maxWidth: horizontalSizeClass == .regular ? Constants.maxHortizontalWidth : .infinity) + .frame(maxWidth: .infinity) + .dynamicTypeSize(...DynamicTypeSize.xxxLarge) + } + .background(Constants.Colors.background) + .animation(.spring, value: viewModel.data.map(ObjectIdentifier.init)) + .onChange(of: dateRange) { oldValue, newValue in + viewModel.dateRange = newValue + } + .onAppear { + context.tracker?.send(.utmMetricStatsScreenShown, properties: [ + "utm_label": utmMetric.label + ]) + } + .navigationTitle(Strings.UTMMetricDetails.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if horizontalSizeClass == .regular { + ToolbarItemGroup(placement: .navigationBarTrailing) { + StatsDateRangeButtons(dateRange: $dateRange) + } + } + } + .safeAreaInset(edge: .bottom) { + if horizontalSizeClass == .compact { + LegacyFloatingDateControl(dateRange: $dateRange) + } + } + } + + private var headerView: some View { + VStack(spacing: Constants.step3) { + HStack(spacing: Constants.step3) { + // UTM Icon + Image(systemName: "chart.line.uptrend.xyaxis") + .font(.system(size: 40)) + .foregroundColor(.secondary) + .frame(width: 60, height: 60) + .background(Color.secondary.opacity(0.1)) + .clipShape(Circle()) + + // Label and metrics + VStack(alignment: .leading, spacing: Constants.step1) { + Text(utmMetric.label) + .font(.title3) + .fontWeight(.semibold) + .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.step3) + } + + 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: SiteMetric.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()) + } + } + } + } + + private func calculatePeriodViews() -> (current: Int, previous: Int?)? { + guard let data = viewModel.data else { return nil } + + // Sum up views from all posts in the current period + let currentViews = data.items.compactMap { item in + (item as? TopListItem.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? TopListItem.Post)?.metrics.views + }.reduce(0, +) + } + + return (current: currentViews, previous: previousViews) + } +} + +#Preview { + NavigationStack { + UTMMetricStatsView( + utmMetric: TopListItem.UTMMetric( + label: "google / cpc", + values: ["google", "cpc"], + metrics: SiteMetricsSet(views: 5000), + posts: [ + TopListItem.Post( + title: "The Future of Technology: AI and Machine Learning", + postID: "1", + postURL: URL(string: "https://example.com/post1"), + date: Date(), + type: "post", + author: nil, + metrics: SiteMetricsSet(views: 1250) + ), + TopListItem.Post( + title: "Understanding Climate Change", + postID: "2", + postURL: URL(string: "https://example.com/post2"), + date: Date(), + type: "post", + author: nil, + metrics: SiteMetricsSet(views: 980) + ) + ] + ), + context: StatsContext.demo + ) + } + .environment(\.context, StatsContext.demo) +} diff --git a/Modules/Sources/JetpackStats/Services/Data/TopListData.swift b/Modules/Sources/JetpackStats/Services/Data/TopListData.swift index fa19f6b90188..e8d41f5bf0fa 100644 --- a/Modules/Sources/JetpackStats/Services/Data/TopListData.swift +++ b/Modules/Sources/JetpackStats/Services/Data/TopListData.swift @@ -105,6 +105,7 @@ extension TopListData { case .searchTerms: mockSearchTerms(metric: metric) case .videos: mockVideos(metric: metric) case .archive: mockArchive(metric: metric) + case .utm: mockUTMMetrics(metric: metric) } } @@ -423,6 +424,54 @@ extension TopListData { } } + private static func mockUTMMetrics(metric: SiteMetric) -> [TopListItem.UTMMetric] { + let utmCampaigns = [ + (["google", "cpc"], 1520), + (["facebook", "social"], 1200), + (["linkedin", "social"], 850), + (["google", "organic"], 670), + (["newsletter", "email"], 450), + (["twitter", "social"], 320), + (["reddit", "social"], 280) + ] + + return utmCampaigns.map { data in + let values = data.0 + let baseValue = data.1 + let label = values.joined(separator: " / ") + let metrics = createMetrics(baseValue: baseValue, metric: metric) + + // Create mock posts for this UTM campaign + let posts = [ + TopListItem.Post( + title: "How to Build a Mobile App", + postID: "12345", + postURL: URL(string: "https://example.com/mobile-app"), + date: Date(), + type: "post", + author: nil, + metrics: createMetrics(baseValue: baseValue / 2, metric: metric) + ), + TopListItem.Post( + title: "Swift Development Guide", + postID: "12346", + postURL: URL(string: "https://example.com/swift-guide"), + date: Date(), + type: "post", + author: nil, + metrics: createMetrics(baseValue: baseValue / 3, metric: metric) + ) + ] + + return TopListItem.UTMMetric( + label: label, + values: values, + metrics: metrics, + posts: posts + ) + } + } + private static func createMetrics(baseValue: Int, metric: SiteMetric) -> SiteMetricsSet { // Add some variation to make it more realistic let variation = Double.random(in: 0.8...1.2) diff --git a/Modules/Sources/JetpackStats/Services/Data/TopListItem+WordPressKit.swift b/Modules/Sources/JetpackStats/Services/Data/TopListItem+WordPressKit.swift index 3963bd9c503a..a3ffe3a6a7ec 100644 --- a/Modules/Sources/JetpackStats/Services/Data/TopListItem+WordPressKit.swift +++ b/Modules/Sources/JetpackStats/Services/Data/TopListItem+WordPressKit.swift @@ -89,6 +89,17 @@ extension TopListItem.Author { } } +extension TopListItem.UTMMetric { + init(_ utmMetric: WordPressKit.StatsUTMMetric, dateFormatter: DateFormatter) { + self.init( + label: utmMetric.label, + values: utmMetric.values, + metrics: SiteMetricsSet(views: utmMetric.viewsCount), + posts: utmMetric.posts.map { TopListItem.Post($0, dateFormatter: dateFormatter) } + ) + } +} + extension TopListItem.ExternalLink { init(_ click: WordPressKit.StatsClick) { self.init( diff --git a/Modules/Sources/JetpackStats/Services/Data/TopListItem.swift b/Modules/Sources/JetpackStats/Services/Data/TopListItem.swift index 1cc7da4ed1e7..d4c0672dcd45 100644 --- a/Modules/Sources/JetpackStats/Services/Data/TopListItem.swift +++ b/Modules/Sources/JetpackStats/Services/Data/TopListItem.swift @@ -188,4 +188,19 @@ extension TopListItem { sectionName.capitalized } } + + struct UTMMetric: Codable, TopListItemProtocol { + let label: String + let values: [String] + var metrics: SiteMetricsSet + var posts: [Post]? + + var id: TopListItemID { + TopListItemID(type: .utm, id: label) + } + + var displayName: String { + label + } + } } diff --git a/Modules/Sources/JetpackStats/Services/Data/TopListItemOptions.swift b/Modules/Sources/JetpackStats/Services/Data/TopListItemOptions.swift index 1ac22f710a75..e4111ddc41d7 100644 --- a/Modules/Sources/JetpackStats/Services/Data/TopListItemOptions.swift +++ b/Modules/Sources/JetpackStats/Services/Data/TopListItemOptions.swift @@ -5,6 +5,7 @@ import Foundation /// Different item types use different options: /// - `.locations`: Uses `locationLevel` to determine granularity (countries, regions, cities) /// - `.devices`: Uses `deviceBreakdown` to determine breakdown type (screensize, platform, browser) +/// - `.utm`: Uses `utmParamGrouping` to determine UTM parameter grouping (source/medium, campaign, etc.) /// - Other item types: These options are ignored struct TopListItemOptions: Equatable, Sendable, Codable, Hashable { /// The granularity level for location data. @@ -15,11 +16,17 @@ struct TopListItemOptions: Equatable, Sendable, Codable, Hashable { /// Only applies to `.devices` item type. var deviceBreakdown: DeviceBreakdown + /// The UTM parameter grouping. + /// Only applies to `.utm` item type. + var utmParamGrouping: UTMParamGrouping + init( locationLevel: LocationLevel = .countries, - deviceBreakdown: DeviceBreakdown = .screensize + deviceBreakdown: DeviceBreakdown = .screensize, + utmParamGrouping: UTMParamGrouping = .sourceMedium ) { self.locationLevel = locationLevel self.deviceBreakdown = deviceBreakdown + self.utmParamGrouping = utmParamGrouping } } diff --git a/Modules/Sources/JetpackStats/Services/Data/TopListItemType.swift b/Modules/Sources/JetpackStats/Services/Data/TopListItemType.swift index a558eec72357..89d76052b391 100644 --- a/Modules/Sources/JetpackStats/Services/Data/TopListItemType.swift +++ b/Modules/Sources/JetpackStats/Services/Data/TopListItemType.swift @@ -11,6 +11,7 @@ enum TopListItemType: String, Identifiable, CaseIterable, Sendable, Codable { case searchTerms case fileDownloads case archive + case utm var id: TopListItemType { self } @@ -26,6 +27,7 @@ enum TopListItemType: String, Identifiable, CaseIterable, Sendable, Codable { case .searchTerms: Strings.SiteDataTypes.searchTerms case .videos: Strings.SiteDataTypes.videos case .archive: Strings.SiteDataTypes.archive + case .utm: Strings.SiteDataTypes.utm } } @@ -41,6 +43,7 @@ enum TopListItemType: String, Identifiable, CaseIterable, Sendable, Codable { case .searchTerms: "magnifyingglass" case .videos: "play.rectangle" case .archive: "folder" + case .utm: "tag" } } @@ -56,11 +59,25 @@ enum TopListItemType: String, Identifiable, CaseIterable, Sendable, Codable { case .searchTerms: Strings.TopListTitles.searchTerms case .videos: Strings.TopListTitles.videos case .archive: Strings.TopListTitles.archive + case .utm: Strings.TopListTitles.utm } } - static let secondaryItems: Set = [ - .externalLinks, .videos, .fileDownloads, .searchTerms, .archive + // MARK: - Item Grouping + + /// Content - What you published + static let contentItems: [TopListItemType] = [ + .postsAndPages, .authors, .videos, .archive + ] + + /// Traffic Sources - How they found you + static let trafficSourceItems: [TopListItemType] = [ + .referrers, .searchTerms, .utm + ] + + /// Audience & Engagement - Who visited & what they did + static let audienceEngagementItems: [TopListItemType] = [ + .locations, .devices, .externalLinks, .fileDownloads ] var documentationURL: URL? { @@ -85,6 +102,8 @@ enum TopListItemType: String, Identifiable, CaseIterable, Sendable, Codable { "https://wordpress.com/support/stats/audience-insights/" case .videos: "https://wordpress.com/support/stats/analyze-content-performance/#see-video-traffic" + case .utm: + "https://wordpress.com/support/stats/understand-traffic-sources/#use-utm-parameters" } } } diff --git a/Modules/Sources/JetpackStats/Services/Data/UTMParamGrouping.swift b/Modules/Sources/JetpackStats/Services/Data/UTMParamGrouping.swift new file mode 100644 index 000000000000..56be7b5d3dbd --- /dev/null +++ b/Modules/Sources/JetpackStats/Services/Data/UTMParamGrouping.swift @@ -0,0 +1,45 @@ +import Foundation + +enum UTMParamGrouping: String, Identifiable, CaseIterable, Sendable, Codable { + case sourceMedium + case campaignSourceMedium + case source + case medium + case campaign + + var id: UTMParamGrouping { self } + + var localizedTitle: String { + switch self { + case .sourceMedium: Strings.UTMParamGroupings.sourceMedium + case .campaignSourceMedium: Strings.UTMParamGroupings.campaignSourceMedium + case .source: Strings.UTMParamGroupings.source + case .medium: Strings.UTMParamGroupings.medium + case .campaign: Strings.UTMParamGroupings.campaign + } + } + + var analyticsName: String { + rawValue + } + + /// Returns true if this grouping represents aggregated values + var isAggregated: Bool { + switch self { + case .sourceMedium, .campaignSourceMedium: + return true + case .source, .medium, .campaign: + return false + } + } + + /// Returns grouped options for display in a picker + static var grouped: [[UTMParamGrouping]] { + [ + // Aggregated values in first section + allCases.filter { $0.isAggregated }, + // Simple values in second section + allCases.filter { !$0.isAggregated } + ] + } +} diff --git a/Modules/Sources/JetpackStats/Services/Extensions/WordPressKit+Extensions.swift b/Modules/Sources/JetpackStats/Services/Extensions/WordPressKit+Extensions.swift index 21e60645388f..c89841e6e660 100644 --- a/Modules/Sources/JetpackStats/Services/Extensions/WordPressKit+Extensions.swift +++ b/Modules/Sources/JetpackStats/Services/Extensions/WordPressKit+Extensions.swift @@ -141,3 +141,15 @@ extension WordPressKit.StatsSiteMetricsResponse.Metric { } } } + +extension WordPressKit.StatsServiceRemoteV2.UTMParam { + init(_ grouping: UTMParamGrouping) { + switch grouping { + case .sourceMedium: self = .sourceMedium + case .campaignSourceMedium: self = .campaignSourceMedium + case .source: self = .source + case .medium: self = .medium + case .campaign: self = .campaign + } + } +} diff --git a/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift b/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift index 795ad0347223..b32e781d8191 100644 --- a/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift +++ b/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift @@ -32,6 +32,7 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { case .fileDownloads: [.downloads] case .searchTerms: [.views, .visitors] case .videos: [.views, .likes] + case .utm: [.views] } } @@ -258,84 +259,7 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { } private func loadRealtimeBaseItems(for dataType: TopListItemType) -> [any TopListItemProtocol] { - let fileName: String - switch dataType { - case .postsAndPages: - fileName = "postsAndPages" - case .archive: - fileName = "archive" - case .referrers: - fileName = "referrers" - case .locations: - fileName = "locations" - case .devices: - fileName = "devices" - case .authors: - fileName = "authors" - case .externalLinks: - fileName = "external-links" - case .fileDownloads: - fileName = "file-downloads" - case .searchTerms: - fileName = "search-terms" - case .videos: - fileName = "videos" - } - - // Load from JSON file - guard let url = Bundle.module.url(forResource: "realtime-\(fileName)", withExtension: "json") else { - print("Failed to find \(fileName).json") - return [] - } - - do { - let data = try Data(contentsOf: url) - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .iso8601 - - // Decode based on data type - switch dataType { - case .referrers: - let referrers = try decoder.decode([TopListItem.Referrer].self, from: data) - return referrers - case .locations: - let locations = try decoder.decode([TopListItem.Location].self, from: data) - return locations - case .devices: - let devices = try decoder.decode([TopListItem.Device].self, from: data) - return devices - case .authors: - let authors = try decoder.decode([TopListItem.Author].self, from: data) - return authors.map { - var copy = $0 - copy.avatarURL = Bundle.module.path(forResource: "author\($0.userId)", ofType: "jpg").map { - URL(filePath: $0) - } - return copy - } - case .externalLinks: - let links = try decoder.decode([TopListItem.ExternalLink].self, from: data) - return links - case .fileDownloads: - let downloads = try decoder.decode([TopListItem.FileDownload].self, from: data) - return downloads - case .searchTerms: - let terms = try decoder.decode([TopListItem.SearchTerm].self, from: data) - return terms - case .videos: - let videos = try decoder.decode([TopListItem.Video].self, from: data) - return videos - case .postsAndPages: - let posts = try decoder.decode([TopListItem.Post].self, from: data) - return posts - case .archive: - let sections = try decoder.decode([TopListItem.ArchiveSection].self, from: data) - return sections - } - } catch { - print("Failed to load \(fileName).json: \(error)") - return [] - } + return [] // No longer needed (will remove soon) } func getPostDetails(for postID: Int) async throws -> StatsPostDetails { @@ -505,6 +429,11 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { return DeviceBreakdown.allCases.map { breakdown in TopListItemOptions(locationLevel: .countries, deviceBreakdown: breakdown) } + case .utm: + // Generate options for all UTM parameter groupings + return UTMParamGrouping.allCases.map { grouping in + TopListItemOptions(locationLevel: .countries, deviceBreakdown: .screensize, utmParamGrouping: grouping) + } default: // For other types, use default options return [TopListItemOptions()] @@ -534,6 +463,8 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { fileName = "historical-search-terms" case .videos: fileName = "historical-videos" + case .utm: + fileName = "historical-utm" } // Load from JSON file @@ -587,6 +518,19 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { case .archive: let sections = try decoder.decode([TopListItem.ArchiveSection].self, from: data) return sections + case .utm: + let metrics = try decoder.decode([TopListItem.UTMMetric].self, from: data) + // Filter based on UTM parameter grouping + let expectedValueCount: Int + switch options.utmParamGrouping { + case .sourceMedium: + expectedValueCount = 2 + case .campaignSourceMedium: + expectedValueCount = 3 + case .source, .medium, .campaign: + expectedValueCount = 1 + } + return metrics.filter { $0.values.count == expectedValueCount } } } catch { print("Failed to load \(fileName).json: \(error)") diff --git a/Modules/Sources/JetpackStats/Services/StatsService.swift b/Modules/Sources/JetpackStats/Services/StatsService.swift index 22d1688423e9..f370a2107a16 100644 --- a/Modules/Sources/JetpackStats/Services/StatsService.swift +++ b/Modules/Sources/JetpackStats/Services/StatsService.swift @@ -27,7 +27,7 @@ actor StatsService: StatsServiceProtocol { let supportedItems: [TopListItemType] = [ .postsAndPages, .authors, .referrers, .locations, .devices, - .externalLinks, .fileDownloads, .searchTerms, .videos, .archive + .externalLinks, .fileDownloads, .searchTerms, .videos, .archive, .utm ] nonisolated func getSupportedMetrics(for item: TopListItemType) -> [SiteMetric] { @@ -42,6 +42,7 @@ actor StatsService: StatsServiceProtocol { case .fileDownloads: [.downloads] case .searchTerms: [.views] case .videos: [.views] + case .utm: [.views] } } @@ -356,6 +357,26 @@ actor StatsService: StatsServiceProtocol { default: throw StatsServiceError.unavailable } + + case .utm: + switch metric { + case .views: + let convertedInterval = convertDateIntervalSiteToLocal(interval) + let utmParam = WordPressKit.StatsServiceRemoteV2.UTMParam(options.utmParamGrouping) + let data = try await service.getUTMStats( + utmParam: utmParam, + startDate: convertedInterval.start, + endDate: convertedInterval.end, + maxResults: limit ?? 0 + ) + let dateFormatter = makeHourlyDateFormatter() + let items = data.utmMetrics.map { + TopListItem.UTMMetric($0, dateFormatter: dateFormatter) + } + return TopListResponse(items: sortItems(items)) + default: + throw StatsServiceError.unavailable + } } } diff --git a/Modules/Sources/JetpackStats/Strings.swift b/Modules/Sources/JetpackStats/Strings.swift index b1c455b8261b..fd9a09ef4d72 100644 --- a/Modules/Sources/JetpackStats/Strings.swift +++ b/Modules/Sources/JetpackStats/Strings.swift @@ -86,6 +86,7 @@ enum Strings { static let fileDownloads = AppLocalizedString("jetpackStats.siteDataTypes.fileDownloads", value: "File Downloads", comment: "File downloads data type") static let searchTerms = AppLocalizedString("jetpackStats.siteDataTypes.searchTerms", value: "Search Terms", comment: "Search terms data type") static let videos = AppLocalizedString("jetpackStats.siteDataTypes.videos", value: "Videos", comment: "Videos data type") + static let utm = AppLocalizedString("jetpackStats.siteDataTypes.utm", value: "UTM", comment: "UTM campaign tracking data type") } enum Countries { @@ -104,6 +105,14 @@ enum Strings { static let browser = AppLocalizedString("jetpackStats.deviceBreakdowns.browser", value: "Browser", comment: "Device breakdown by browser") } + enum UTMParamGroupings { + static let source = AppLocalizedString("jetpackStats.utmParamGroupings.source", value: "Source", comment: "UTM parameter for source only") + static let medium = AppLocalizedString("jetpackStats.utmParamGroupings.medium", value: "Medium", comment: "UTM parameter for medium only") + static let campaign = AppLocalizedString("jetpackStats.utmParamGroupings.campaign", value: "Campaign", comment: "UTM parameter for campaign only") + static var sourceMedium: String { "\(source) / \(medium)" } + static var campaignSourceMedium: String { "\(campaign) / \(source) / \(medium)" } + } + enum Buttons { static let cancel = AppLocalizedString("jetpackStats.button.cancel", value: "Cancel", comment: "Cancel button") static let apply = AppLocalizedString("jetpackStats.button.apply", value: "Apply", comment: "Apply button") @@ -168,6 +177,7 @@ enum Strings { static let fileDownloads = AppLocalizedString("jetpackStats.topListColumnTitle.fileDownloads", value: "File", comment: "Table column title for Top List card") static let searchTerms = AppLocalizedString("jetpackStats.topListColumnTitle.searchTerms", value: "Term", comment: "Table column title for Top List card") static let videos = AppLocalizedString("jetpackStats.topListColumnTitle.videos", value: "Video", comment: "Table column title for Top List card") + static let utm = AppLocalizedString("jetpackStats.topListColumnTitle.utm", value: "Campaign", comment: "Table column title for UTM Top List card") static let top10 = AppLocalizedString("jetpackStats.postDetails.top10", value: "Top 10", comment: "Section title") static let top50 = AppLocalizedString("jetpackStats.postDetails.top50", value: "Top 50", comment: "Section title") } @@ -244,6 +254,18 @@ enum Strings { static let childLinks = AppLocalizedString("jetpackStats.externalLinkDetails.childLinks", value: "Sub-links", comment: "Section title for the list of child links") } + enum UTMMetricDetails { + static let title = AppLocalizedString("jetpackStats.utmMetricDetails.title", value: "UTM Campaign", comment: "Title for the UTM campaign details screen") + static let topPosts = AppLocalizedString("jetpackStats.utmMetricDetails.topPosts", value: "Top Posts", comment: "Section title for top posts from this UTM campaign") + + static func postCount(_ count: Int) -> String { + let format = count == 1 + ? AppLocalizedString("jetpackStats.utmMetricDetails.postCount.singular", value: "%1$d post", comment: "Singular post count for UTM metrics. %1$d is the number.") + : AppLocalizedString("jetpackStats.utmMetricDetails.postCount.plural", value: "%1$d posts", comment: "Plural post count for UTM metrics. %1$d is the number.") + return String.localizedStringWithFormat(format, count) + } + } + enum ContextMenuActions { static let openInBrowser = AppLocalizedString("jetpackStats.contextMenu.openInBrowser", value: "Open in Browser", comment: "Context menu action to open link in browser") static let copyURL = AppLocalizedString("jetpackStats.contextMenu.copyURL", value: "Copy URL", comment: "Context menu action to copy URL") diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListUTMMetricRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListUTMMetricRowView.swift new file mode 100644 index 000000000000..ad3f66fa85fb --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListUTMMetricRowView.swift @@ -0,0 +1,23 @@ +import SwiftUI + +struct TopListUTMMetricRowView: View { + let item: TopListItem.UTMMetric + + var body: some View { + HStack(spacing: Constants.step0_5) { + VStack(alignment: .leading, spacing: 1) { + Text(item.label) + .font(.body) + .foregroundColor(.primary) + .lineLimit(1) + + if let posts = item.posts, !posts.isEmpty { + Text(Strings.UTMMetricDetails.postCount(posts.count)) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + } + } +} diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift index f2d9bec0dd89..7baa3a463d72 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift @@ -87,6 +87,8 @@ struct TopListItemView: View { TopListArchiveItemRowView(item: archiveItem) case let archiveSection as TopListItem.ArchiveSection: TopListArchiveSectionRowView(item: archiveSection) + case let utmMetric as TopListItem.UTMMetric: + TopListUTMMetricRowView(item: utmMetric) default: let _ = assertionFailure("unsupported item: \(item)") EmptyView() @@ -141,6 +143,8 @@ private extension TopListItemView { return true case is TopListItem.ExternalLink: return true + case is TopListItem.UTMMetric: + return true default: return false } @@ -184,6 +188,11 @@ private extension TopListItemView { .environment(\.context, context) .environment(\.router, router) router.navigate(to: detailsView, title: Strings.ExternalLinkDetails.title) + case let utmMetric as TopListItem.UTMMetric: + let detailsView = UTMMetricStatsView(utmMetric: utmMetric, initialDateRange: dateRange, context: context) + .environment(\.context, context) + .environment(\.router, router) + router.navigate(to: detailsView, title: Strings.UTMMetricDetails.title) default: break } diff --git a/Modules/Sources/WordPressKit/StatsServiceRemoteV2.swift b/Modules/Sources/WordPressKit/StatsServiceRemoteV2.swift index bf2cb6e65fb2..f25b4e4bb97b 100644 --- a/Modules/Sources/WordPressKit/StatsServiceRemoteV2.swift +++ b/Modules/Sources/WordPressKit/StatsServiceRemoteV2.swift @@ -581,3 +581,55 @@ extension StatsServiceRemoteV2 { return try result.get().body } } + +// MARK: - UTM Stats + +extension StatsServiceRemoteV2 { + /// Represents the UTM parameter grouping options + public enum UTMParam: String { + case sourceMedium = "utm_source,utm_medium" + case campaignSourceMedium = "utm_campaign,utm_source,utm_medium" + case source = "utm_source" + case medium = "utm_medium" + case campaign = "utm_campaign" + } + + /// Fetches UTM metrics for a specific UTM parameter grouping + /// - Parameters: + /// - utmParam: The UTM parameter grouping (e.g., source/medium, campaign) + /// - startDate: The start date for the stats query + /// - endDate: The end date for the stats query + /// - maxResults: Maximum number of results to return (default: 10) + /// - postId: Optional post ID to filter results for a specific post + /// - Returns: UTM metrics data + public func getUTMStats( + utmParam: UTMParam, + startDate: Date, + endDate: Date, + maxResults: Int = 10, + postId: Int? = nil + ) async throws -> StatsUTMTimeIntervalData { + let pathComponent = "stats/utm/\(utmParam.rawValue)" + let path = self.path(forEndpoint: "sites/\(siteID)/\(pathComponent)", withVersion: ._1_1) + + let dateFormatter = periodDataQueryDateFormatter + var parameters: [String: String] = [ + "start_date": dateFormatter.string(from: startDate), + "date": dateFormatter.string(from: endDate), + "max": String(maxResults), + "query_top_posts": "true" + ] + + if let postId { + parameters["post_id"] = String(postId) + } + + let result = await wordPressComRestApi.perform( + .get, + URLString: path, + parameters: parameters, + type: StatsUTMTimeIntervalData.self + ) + return try result.get().body + } +} diff --git a/Modules/Sources/WordPressKit/StatsUTMTimeIntervalData.swift b/Modules/Sources/WordPressKit/StatsUTMTimeIntervalData.swift new file mode 100644 index 000000000000..5ebe9bcf5bb5 --- /dev/null +++ b/Modules/Sources/WordPressKit/StatsUTMTimeIntervalData.swift @@ -0,0 +1,129 @@ +import Foundation + +public struct StatsUTMTimeIntervalData: Decodable { + public let utmMetrics: [StatsUTMMetric] + + enum CodingKeys: String, CodingKey { + case topUTMValues = "top_utm_values" + case topPosts = "top_posts" + } + + public init(utmMetrics: [StatsUTMMetric]) { + self.utmMetrics = utmMetrics + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + // Decode top UTM values (backend may return [] instead of {} when empty) + let topUTMValues: [String: Int] + if let dict = try? container.decode([String: Int].self, forKey: .topUTMValues) { + topUTMValues = dict + } else if let array = try? container.decode([Int].self, forKey: .topUTMValues), array.isEmpty { + // Backend returned empty array instead of empty dict + topUTMValues = [:] + } else { + throw DecodingError.typeMismatch( + [String: Int].self, + DecodingError.Context( + codingPath: container.codingPath + [CodingKeys.topUTMValues], + debugDescription: "Expected dictionary or empty array for top_utm_values" + ) + ) + } + + // Decode top posts (backend may return [] instead of {} when empty) + let topPosts: [String: [UTMPost]] + if let dict = try? container.decode([String: [UTMPost]].self, forKey: .topPosts) { + topPosts = dict + } else if let array = try? container.decode([UTMPost].self, forKey: .topPosts), array.isEmpty { + // Backend returned empty array instead of empty dict + topPosts = [:] + } else { + // Field is missing or null + topPosts = [:] + } + + // Parse and sort UTM metrics by view count + var metrics: [StatsUTMMetric] = [] + + for (key, viewsCount) in topUTMValues { + // Parse the JSON key to extract values + let values = Self.parseUTMKey(key) + let label = values.joined(separator: " / ") + + // Get top posts for this UTM combination + let postsArray = topPosts[key] ?? [] + let posts = postsArray.map { $0.toStatsTopPost() } + + metrics.append(StatsUTMMetric( + label: label, + values: values, + viewsCount: viewsCount, + posts: posts + )) + } + + // Sort by view count descending + self.utmMetrics = metrics.sorted { $0.viewsCount > $1.viewsCount } + } +} + +public struct StatsUTMMetric { + public let label: String + public let values: [String] + public let viewsCount: Int + public let posts: [StatsTopPost] + + public init(label: String, + values: [String], + viewsCount: Int, + posts: [StatsTopPost]) { + self.label = label + self.values = values + self.viewsCount = viewsCount + self.posts = posts + } +} + +// Helper struct for decoding posts +private struct UTMPost: Decodable { + let id: Int + let title: String + let views: Int + let href: String + + func toStatsTopPost() -> StatsTopPost { + StatsTopPost( + title: title, + date: nil, + postID: id, + postURL: URL(string: href), + viewsCount: views, + kind: .post + ) + } +} + +// MARK: - Helpers + +extension StatsUTMTimeIntervalData { + /// Parses a UTM key from the API response + /// - Examples: + /// - `"google"` -> `["google"]` + /// - `["google","cpc"]` -> `["google", "cpc"]` + /// - `["spring-sale","google","cpc"]` -> `["spring-sale", "google", "cpc"]` + private static func parseUTMKey(_ key: String) -> [String] { + // Try to parse as JSON array first + if key.hasPrefix("["), + let data = key.data(using: .utf8), + let values = try? JSONDecoder().decode([String].self, from: data) { + return values + } + + // If it's a single value, return it in an array + // Remove surrounding quotes if present + let trimmed = key.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + return [trimmed] + } +} diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 8a3aa38f605d..f051e455168c 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -2,6 +2,7 @@ ----- * [**] Stats: Add new "Adds" tab to show WordAdds earnings and stats [#25165] * [**] Stats: Add "Devices" view to the "Traffic" tab [#25176] +* [**] Stats: Add "UTM" view to the "Traffic" tab [#25178] 26.6 ----- diff --git a/Tests/WordPressKitTests/WordPressKitTests/Mock Data/stats-utm-empty.json b/Tests/WordPressKitTests/WordPressKitTests/Mock Data/stats-utm-empty.json new file mode 100644 index 000000000000..4e757d1abb74 --- /dev/null +++ b/Tests/WordPressKitTests/WordPressKitTests/Mock Data/stats-utm-empty.json @@ -0,0 +1,6 @@ +{ + "date": "2026-01-28", + "period": "day", + "top_utm_values": [], + "top_posts": [] +} diff --git a/Tests/WordPressKitTests/WordPressKitTests/Mock Data/stats-utm-source-medium.json b/Tests/WordPressKitTests/WordPressKitTests/Mock Data/stats-utm-source-medium.json new file mode 100644 index 000000000000..e7a1e8a44ca5 --- /dev/null +++ b/Tests/WordPressKitTests/WordPressKitTests/Mock Data/stats-utm-source-medium.json @@ -0,0 +1,75 @@ +{ + "top_utm_values": { + "[\"google\",\"cpc\"]": 152, + "[\"facebook\",\"social\"]": 120, + "[\"linkedin\",\"social\"]": 85, + "[\"google\",\"organic\"]": 67, + "[\"newsletter\",\"email\"]": 45, + "[\"twitter\",\"social\"]": 32, + "[\"reddit\",\"social\"]": 28 + }, + "top_posts": { + "[\"google\",\"cpc\"]": [ + { + "id": 12345, + "title": "How to Build a Mobile App", + "views": 75, + "href": "https://example.com/how-to-build-mobile-app" + }, + { + "id": 12346, + "title": "Swift Development Guide", + "views": 50, + "href": "https://example.com/swift-guide" + } + ], + "[\"facebook\",\"social\"]": [ + { + "id": 12347, + "title": "Social Media Tips", + "views": 60, + "href": "https://example.com/social-media-tips" + } + ], + "[\"linkedin\",\"social\"]": [ + { + "id": 12348, + "title": "Professional Networking Guide", + "views": 45, + "href": "https://example.com/networking-guide" + } + ], + "[\"google\",\"organic\"]": [ + { + "id": 12349, + "title": "SEO Best Practices", + "views": 40, + "href": "https://example.com/seo-best-practices" + } + ], + "[\"newsletter\",\"email\"]": [ + { + "id": 12350, + "title": "Email Marketing Strategies", + "views": 30, + "href": "https://example.com/email-marketing" + } + ], + "[\"twitter\",\"social\"]": [ + { + "id": 12351, + "title": "Twitter Marketing Tips", + "views": 20, + "href": "https://example.com/twitter-marketing" + } + ], + "[\"reddit\",\"social\"]": [ + { + "id": 12352, + "title": "Reddit Community Building", + "views": 15, + "href": "https://example.com/reddit-community" + } + ] + } +} diff --git a/Tests/WordPressKitTests/WordPressKitTests/Tests/StatsRemoteV2Tests.swift b/Tests/WordPressKitTests/WordPressKitTests/Tests/StatsRemoteV2Tests.swift index 32b9241261e8..5358ac2a283e 100644 --- a/Tests/WordPressKitTests/WordPressKitTests/Tests/StatsRemoteV2Tests.swift +++ b/Tests/WordPressKitTests/WordPressKitTests/Tests/StatsRemoteV2Tests.swift @@ -33,6 +33,8 @@ class StatsRemoteV2Tests: RemoteTestCase, RESTTestable { let getDeviceScreensizeFilename = "stats-devices-screensize.json" let getDevicePlatformFilename = "stats-devices-platform.json" let getDeviceBrowserFilename = "stats-devices-browser.json" + let getUTMStatsFilename = "stats-utm-source-medium.json" + let getUTMStatsEmptyFilename = "stats-utm-empty.json" // MARK: - Properties @@ -56,6 +58,7 @@ class StatsRemoteV2Tests: RemoteTestCase, RESTTestable { var siteDeviceScreensizeEndpoint: String { return "sites/\(siteID)/stats/devices/screensize/" } var siteDevicePlatformEndpoint: String { return "sites/\(siteID)/stats/devices/platform/" } var siteDeviceBrowserEndpoint: String { return "sites/\(siteID)/stats/devices/browser/" } + var siteUTMStatsEndpoint: String { return "sites/\(siteID)/stats/utm/utm_source,utm_medium" } func toggleSpamStateEndpoint(for referrerDomain: String, markAsSpam: Bool) -> String { let action = markAsSpam ? "new" : "delete" @@ -1008,4 +1011,48 @@ class StatsRemoteV2Tests: RemoteTestCase, RESTTestable { XCTAssertEqual(browserData.items.first?.name, "chrome") XCTAssertEqual(browserData.items.first?.value, 1063.0) } + + func testFetchUTMStats() async throws { + stubRemoteResponse(siteUTMStatsEndpoint, filename: getUTMStatsFilename, contentType: .ApplicationJSON) + + let utmData = try await remote.getUTMStats(utmParam: .sourceMedium, startDate: Date(), endDate: Date(), maxResults: 10) + + // Check we have the expected number of UTM metrics + XCTAssertEqual(utmData.utmMetrics.count, 7) + + // Check first UTM metric (highest views) + let firstMetric = utmData.utmMetrics.first + XCTAssertEqual(firstMetric?.label, "google / cpc") + XCTAssertEqual(firstMetric?.values, ["google", "cpc"]) + XCTAssertEqual(firstMetric?.viewsCount, 152) + XCTAssertEqual(firstMetric?.posts.count, 2) + + // Check first post of first metric + let firstPost = firstMetric?.posts.first + XCTAssertEqual(firstPost?.title, "How to Build a Mobile App") + XCTAssertEqual(firstPost?.postID, 12345) + XCTAssertEqual(firstPost?.viewsCount, 75) + + // Check second UTM metric + let secondMetric = utmData.utmMetrics[1] + XCTAssertEqual(secondMetric.label, "facebook / social") + XCTAssertEqual(secondMetric.values, ["facebook", "social"]) + XCTAssertEqual(secondMetric.viewsCount, 120) + XCTAssertEqual(secondMetric.posts.count, 1) + + // Check that metrics are sorted by views descending + for i in 0..<(utmData.utmMetrics.count - 1) { + XCTAssertGreaterThanOrEqual(utmData.utmMetrics[i].viewsCount, utmData.utmMetrics[i + 1].viewsCount) + } + } + + func testFetchUTMStatsWithEmptyArrays() async throws { + // Test that backend returning empty arrays instead of empty dicts is handled gracefully + stubRemoteResponse(siteUTMStatsEndpoint, filename: getUTMStatsEmptyFilename, contentType: .ApplicationJSON) + + let utmData = try await remote.getUTMStats(utmParam: .sourceMedium, startDate: Date(), endDate: Date(), maxResults: 10) + + // Should return empty metrics array without crashing + XCTAssertEqual(utmData.utmMetrics.count, 0) + } } diff --git a/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent+JetpackStats.swift b/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent+JetpackStats.swift index dd29bf81ecee..682a8dfa7139 100644 --- a/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent+JetpackStats.swift +++ b/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent+JetpackStats.swift @@ -15,6 +15,7 @@ extension StatsEvent { case .archiveStatsScreenShown: .jetpackStatsArchiveStatsScreenShown case .externalLinkStatsScreenShown: .jetpackStatsExternalLinkStatsScreenShown case .referrerStatsScreenShown: .jetpackStatsReferrerStatsScreenShown + case .utmMetricStatsScreenShown: .jetpackStatsUtmMetricStatsScreenShown case .dateRangePresetSelected: .jetpackStatsDateRangePresetSelected case .customDateRangeSelected: .jetpackStatsCustomDateRangeSelected case .dateNavigationButtonTapped: .jetpackStatsDateNavigationButtonTapped @@ -32,6 +33,7 @@ extension StatsEvent { case .topListItemTapped: .jetpackStatsTopListItemTapped case .locationLevelChanged: .jetpackStatsLocationLevelChanged case .deviceBreakdownChanged: .jetpackStatsDeviceBreakdownChanged + case .utmParamGroupingChanged: .jetpackStatsUtmParamGroupingChanged case .statsTabSelected: .jetpackStatsTabSelected case .errorEncountered: .jetpackStatsErrorEncountered } diff --git a/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift b/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift index 79a583c5abbd..d7e9485d38d8 100644 --- a/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift +++ b/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift @@ -675,8 +675,10 @@ import WordPressShared case jetpackStatsTopListItemTapped case jetpackStatsLocationLevelChanged case jetpackStatsDeviceBreakdownChanged + case jetpackStatsUtmParamGroupingChanged // Navigation Events + case jetpackStatsUtmMetricStatsScreenShown case jetpackStatsTabSelected // Error Events @@ -1849,8 +1851,12 @@ import WordPressShared return "jetpack_stats_location_level_changed" case .jetpackStatsDeviceBreakdownChanged: return "jetpack_stats_device_breakdown_changed" + case .jetpackStatsUtmParamGroupingChanged: + return "jetpack_stats_utm_param_grouping_changed" // Navigation Events + case .jetpackStatsUtmMetricStatsScreenShown: + return "jetpack_stats_utm_metric_stats_screen_shown" case .jetpackStatsTabSelected: return "jetpack_stats_tab_selected" From f48ea715fe4310d7a5482788a590724e7ea0acb5 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 30 Jan 2026 14:14:33 -0500 Subject: [PATCH 2/2] Simplify decoding in StatsUTMTimeIntervalData --- .../StatsUTMTimeIntervalData.swift | 18 +++++++++--------- .../Tests/StatsRemoteV2Tests.swift | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/Modules/Sources/WordPressKit/StatsUTMTimeIntervalData.swift b/Modules/Sources/WordPressKit/StatsUTMTimeIntervalData.swift index 5ebe9bcf5bb5..6ed3f13bb5b3 100644 --- a/Modules/Sources/WordPressKit/StatsUTMTimeIntervalData.swift +++ b/Modules/Sources/WordPressKit/StatsUTMTimeIntervalData.swift @@ -114,16 +114,16 @@ extension StatsUTMTimeIntervalData { /// - `["google","cpc"]` -> `["google", "cpc"]` /// - `["spring-sale","google","cpc"]` -> `["spring-sale", "google", "cpc"]` private static func parseUTMKey(_ key: String) -> [String] { - // Try to parse as JSON array first - if key.hasPrefix("["), - let data = key.data(using: .utf8), - let values = try? JSONDecoder().decode([String].self, from: data) { + let decoder = JSONDecoder() + guard let data = key.data(using: .utf8) else { + return [] // Should never happen + } + if let values = try? decoder.decode([String].self, from: data) { return values } - - // If it's a single value, return it in an array - // Remove surrounding quotes if present - let trimmed = key.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) - return [trimmed] + if let string = try? decoder.decode(String.self, from: data) { + return [string] // Defensive code, should never happen + } + return [] } } diff --git a/Tests/WordPressKitTests/WordPressKitTests/Tests/StatsRemoteV2Tests.swift b/Tests/WordPressKitTests/WordPressKitTests/Tests/StatsRemoteV2Tests.swift index 5358ac2a283e..da336b70948b 100644 --- a/Tests/WordPressKitTests/WordPressKitTests/Tests/StatsRemoteV2Tests.swift +++ b/Tests/WordPressKitTests/WordPressKitTests/Tests/StatsRemoteV2Tests.swift @@ -1032,6 +1032,14 @@ class StatsRemoteV2Tests: RemoteTestCase, RESTTestable { XCTAssertEqual(firstPost?.title, "How to Build a Mobile App") XCTAssertEqual(firstPost?.postID, 12345) XCTAssertEqual(firstPost?.viewsCount, 75) + XCTAssertEqual(firstPost?.postURL, URL(string: "https://example.com/how-to-build-mobile-app")) + + // Check second post of first metric + let secondPost = firstMetric?.posts[1] + XCTAssertEqual(secondPost?.title, "Swift Development Guide") + XCTAssertEqual(secondPost?.postID, 12346) + XCTAssertEqual(secondPost?.viewsCount, 50) + XCTAssertEqual(secondPost?.postURL, URL(string: "https://example.com/swift-guide")) // Check second UTM metric let secondMetric = utmData.utmMetrics[1] @@ -1040,6 +1048,13 @@ class StatsRemoteV2Tests: RemoteTestCase, RESTTestable { XCTAssertEqual(secondMetric.viewsCount, 120) XCTAssertEqual(secondMetric.posts.count, 1) + // Check post of second metric + let secondMetricPost = secondMetric.posts.first + XCTAssertEqual(secondMetricPost?.title, "Social Media Tips") + XCTAssertEqual(secondMetricPost?.postID, 12347) + XCTAssertEqual(secondMetricPost?.viewsCount, 60) + XCTAssertEqual(secondMetricPost?.postURL, URL(string: "https://example.com/social-media-tips")) + // Check that metrics are sorted by views descending for i in 0..<(utmData.utmMetrics.count - 1) { XCTAssertGreaterThanOrEqual(utmData.utmMetrics[i].viewsCount, utmData.utmMetrics[i + 1].viewsCount)