From b743e7c4e6f7f8fe280d5248c6ebfbc20f6fd804 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 18 Jul 2025 13:25:19 -0400 Subject: [PATCH 001/349] Add JetpackStats --- Modules/Package.resolved | 4 +- Modules/Package.swift | 13 +- .../JetpackStats/Cards/ChartCard.swift | 224 +++++++ .../Cards/ChartCardViewModel.swift | 143 +++++ .../Cards/RealtimeMetricsCard.swift | 119 ++++ .../Cards/RealtimeTopListCard.swift | 168 ++++++ .../Cards/RealtimeTopListCardViewModel.swift | 66 +++ .../JetpackStats/Cards/TopListCard.swift | 208 +++++++ .../Cards/TopListCardViewModel.swift | 125 ++++ .../JetpackStats/Charts/BarChartView.swift | 170 ++++++ .../JetpackStats/Charts/ChartData.swift | 80 +++ .../JetpackStats/Charts/LineChartView.swift | 223 +++++++ Modules/Sources/JetpackStats/Constants.swift | 48 ++ .../Calendar+ComparisonPeriod.swift | 38 ++ .../Extensions/Calendar+Navigation.swift | 111 ++++ .../Extensions/Calendar+Presets.swift | 131 +++++ .../Extensions/UIColor+Extensions.swift | 13 + .../Resources/Mocks/Avatars/author1.jpg | Bin 0 -> 5516 bytes .../Resources/Mocks/Avatars/author2.jpg | Bin 0 -> 5541 bytes .../Resources/Mocks/Avatars/author3.jpg | Bin 0 -> 6137 bytes .../Resources/Mocks/Avatars/author4.jpg | Bin 0 -> 6797 bytes .../Resources/Mocks/Avatars/author5.jpg | Bin 0 -> 5734 bytes .../Resources/Mocks/Avatars/author6.jpg | Bin 0 -> 6649 bytes .../HistoricalData/historical-authors.json | 132 +++++ .../HistoricalData/historical-locations.json | 132 +++++ .../HistoricalData/historical-pages.json | 72 +++ .../HistoricalData/historical-posts.json | 170 ++++++ .../HistoricalData/historical-referrers.json | 122 ++++ .../Mocks/RealtimeData/realtime-authors.json | 82 +++ .../RealtimeData/realtime-locations.json | 66 +++ .../Mocks/RealtimeData/realtime-pages.json | 80 +++ .../Mocks/RealtimeData/realtime-posts.json | 122 ++++ .../RealtimeData/realtime-referrers.json | 58 ++ .../Screens/ChartDataListView.swift | 305 ++++++++++ .../Screens/InsightsTabView.swift | 26 + .../Screens/RealtimeTabView.swift | 79 +++ .../JetpackStats/Screens/StatsMainView.swift | 50 ++ .../Screens/SubscribersTabView.swift | 26 + .../JetpackStats/Screens/TrafficTabView.swift | 124 ++++ .../Services/Data/DataPoint.swift | 50 ++ .../Services/Data/SiteMetric.swift | 76 +++ .../Services/Data/SiteStatsData.swift | 5 + .../Services/Data/TopListChartData.swift | 282 +++++++++ .../Services/Data/TopListData.swift | 78 +++ .../Services/Data/TopListItemType.swift | 58 ++ .../Services/Mocks/MockStatsService.swift | 451 ++++++++++++++ .../Services/Mocks/StatsDataAggregator.swift | 114 ++++ .../JetpackStats/Services/StatsService.swift | 333 +++++++++++ .../Services/StatsServiceProtocol.swift | 7 + .../Sources/JetpackStats/StatsContext.swift | 59 ++ Modules/Sources/JetpackStats/Strings.swift | 90 +++ .../Utilities/AppLocalizedString.swift | 28 + .../Utilities/DateRangeGranularity.swift | 58 ++ .../Formatters/StatsDateFormatter.swift | 36 ++ .../Formatters/StatsDateRangeFormatter.swift | 156 +++++ .../Formatters/StatsValueFormatter.swift | 86 +++ .../Utilities/Modifiers/CardModifier.swift | 20 + .../Modifiers/ChartSelectionModifier.swift | 13 + .../Modifiers/ScrollOffsetModifier.swift | 26 + .../Utilities/SelectedDataPoints.swift | 47 ++ .../Utilities/StatsDateRange.swift | 118 ++++ .../Utilities/TrendViewModel.swift | 140 +++++ .../JetpackStats/Views/AvatarView.swift | 48 ++ .../Views/BadgeTrendIndicator.swift | 46 ++ .../Views/ChartAxisDateLabel.swift | 61 ++ .../JetpackStats/Views/ChartLegendView.swift | 36 ++ .../Views/ChartValueTooltipView.swift | 101 ++++ .../Views/ChartValuesSummaryView.swift | 61 ++ .../Views/CustomDateRangePicker.swift | 317 ++++++++++ .../Views/LegacyFloatingDateControl.swift | 138 +++++ .../Views/MetricsOverviewTabView.swift | 158 +++++ .../JetpackStats/Views/SimpleErrorView.swift | 14 + .../Views/StatsCardTitleView.swift | 81 +++ .../Views/StatsDateRangePickerMenu.swift | 83 +++ .../JetpackStats/Views/StatsTabBar.swift | 78 +++ .../TopList/Rows/TopListAuthorRowView.swift | 26 + .../Rows/TopListExternalLinkRowView.swift | 29 + .../TopList/Rows/TopListLocationRowView.swift | 28 + .../TopList/Rows/TopListPostRowView.swift | 22 + .../TopList/Rows/TopListReferrerRowView.swift | 22 + .../TopList/TopListItemBarBackground.swift | 29 + .../Views/TopList/TopListItemView.swift | 49 ++ .../Views/TopList/TopListItemsView.swift | 69 +++ .../Views/TopList/TopListMetricsView.swift | 45 ++ .../CalendarNavigationTests.swift | 550 ++++++++++++++++++ .../CalendarPresetsTests.swift | 239 ++++++++ .../JetpackStatsTests/DataPointTests.swift | 143 +++++ .../JetpackStatsTests/DateIntervalTests.swift | 51 ++ .../DateRangeComparisonPeriodTests.swift | 248 ++++++++ .../DateRangeGranularityTests.swift | 174 ++++++ .../MockStatsServiceTests.swift | 47 ++ .../StatsDataAggregationTests.swift | 250 ++++++++ .../StatsDateFormatterTests.swift | 43 ++ .../StatsDateRangeFormatterTests.swift | 228 ++++++++ .../StatsDateRangeTests.swift | 114 ++++ .../StatsValueFormatterTests.swift | 146 +++++ .../Tests/JetpackStatsTests/TestHelpers.swift | 31 + .../TrendViewModelTests.swift | 157 +++++ Sources/Miniature/ContentView.swift | 1 + .../BuildInformation/FeatureFlag.swift | 4 + .../Classes/Utility/ContentCoordinator.swift | 12 +- .../BlogDetailsViewController+Swift.swift | 18 +- .../ExperimentalFeaturesDataProvider.swift | 1 + .../Stats/StatsHostingViewController.swift | 164 ++++++ .../Stats/StatsViewController.swift | 11 + 105 files changed, 10023 insertions(+), 11 deletions(-) create mode 100644 Modules/Sources/JetpackStats/Cards/ChartCard.swift create mode 100644 Modules/Sources/JetpackStats/Cards/ChartCardViewModel.swift create mode 100644 Modules/Sources/JetpackStats/Cards/RealtimeMetricsCard.swift create mode 100644 Modules/Sources/JetpackStats/Cards/RealtimeTopListCard.swift create mode 100644 Modules/Sources/JetpackStats/Cards/RealtimeTopListCardViewModel.swift create mode 100644 Modules/Sources/JetpackStats/Cards/TopListCard.swift create mode 100644 Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift create mode 100644 Modules/Sources/JetpackStats/Charts/BarChartView.swift create mode 100644 Modules/Sources/JetpackStats/Charts/ChartData.swift create mode 100644 Modules/Sources/JetpackStats/Charts/LineChartView.swift create mode 100644 Modules/Sources/JetpackStats/Constants.swift create mode 100644 Modules/Sources/JetpackStats/Extensions/Calendar+ComparisonPeriod.swift create mode 100644 Modules/Sources/JetpackStats/Extensions/Calendar+Navigation.swift create mode 100644 Modules/Sources/JetpackStats/Extensions/Calendar+Presets.swift create mode 100644 Modules/Sources/JetpackStats/Extensions/UIColor+Extensions.swift create mode 100644 Modules/Sources/JetpackStats/Resources/Mocks/Avatars/author1.jpg create mode 100644 Modules/Sources/JetpackStats/Resources/Mocks/Avatars/author2.jpg create mode 100644 Modules/Sources/JetpackStats/Resources/Mocks/Avatars/author3.jpg create mode 100644 Modules/Sources/JetpackStats/Resources/Mocks/Avatars/author4.jpg create mode 100644 Modules/Sources/JetpackStats/Resources/Mocks/Avatars/author5.jpg create mode 100644 Modules/Sources/JetpackStats/Resources/Mocks/Avatars/author6.jpg create mode 100644 Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-authors.json create mode 100644 Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-locations.json create mode 100644 Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-pages.json create mode 100644 Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-posts.json create mode 100644 Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-referrers.json create mode 100644 Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-authors.json create mode 100644 Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-locations.json create mode 100644 Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-pages.json create mode 100644 Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-posts.json create mode 100644 Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-referrers.json create mode 100644 Modules/Sources/JetpackStats/Screens/ChartDataListView.swift create mode 100644 Modules/Sources/JetpackStats/Screens/InsightsTabView.swift create mode 100644 Modules/Sources/JetpackStats/Screens/RealtimeTabView.swift create mode 100644 Modules/Sources/JetpackStats/Screens/StatsMainView.swift create mode 100644 Modules/Sources/JetpackStats/Screens/SubscribersTabView.swift create mode 100644 Modules/Sources/JetpackStats/Screens/TrafficTabView.swift create mode 100644 Modules/Sources/JetpackStats/Services/Data/DataPoint.swift create mode 100644 Modules/Sources/JetpackStats/Services/Data/SiteMetric.swift create mode 100644 Modules/Sources/JetpackStats/Services/Data/SiteStatsData.swift create mode 100644 Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift create mode 100644 Modules/Sources/JetpackStats/Services/Data/TopListData.swift create mode 100644 Modules/Sources/JetpackStats/Services/Data/TopListItemType.swift create mode 100644 Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift create mode 100644 Modules/Sources/JetpackStats/Services/Mocks/StatsDataAggregator.swift create mode 100644 Modules/Sources/JetpackStats/Services/StatsService.swift create mode 100644 Modules/Sources/JetpackStats/Services/StatsServiceProtocol.swift create mode 100644 Modules/Sources/JetpackStats/StatsContext.swift create mode 100644 Modules/Sources/JetpackStats/Strings.swift create mode 100644 Modules/Sources/JetpackStats/Utilities/AppLocalizedString.swift create mode 100644 Modules/Sources/JetpackStats/Utilities/DateRangeGranularity.swift create mode 100644 Modules/Sources/JetpackStats/Utilities/Formatters/StatsDateFormatter.swift create mode 100644 Modules/Sources/JetpackStats/Utilities/Formatters/StatsDateRangeFormatter.swift create mode 100644 Modules/Sources/JetpackStats/Utilities/Formatters/StatsValueFormatter.swift create mode 100644 Modules/Sources/JetpackStats/Utilities/Modifiers/CardModifier.swift create mode 100644 Modules/Sources/JetpackStats/Utilities/Modifiers/ChartSelectionModifier.swift create mode 100644 Modules/Sources/JetpackStats/Utilities/Modifiers/ScrollOffsetModifier.swift create mode 100644 Modules/Sources/JetpackStats/Utilities/SelectedDataPoints.swift create mode 100644 Modules/Sources/JetpackStats/Utilities/StatsDateRange.swift create mode 100644 Modules/Sources/JetpackStats/Utilities/TrendViewModel.swift create mode 100644 Modules/Sources/JetpackStats/Views/AvatarView.swift create mode 100644 Modules/Sources/JetpackStats/Views/BadgeTrendIndicator.swift create mode 100644 Modules/Sources/JetpackStats/Views/ChartAxisDateLabel.swift create mode 100644 Modules/Sources/JetpackStats/Views/ChartLegendView.swift create mode 100644 Modules/Sources/JetpackStats/Views/ChartValueTooltipView.swift create mode 100644 Modules/Sources/JetpackStats/Views/ChartValuesSummaryView.swift create mode 100644 Modules/Sources/JetpackStats/Views/CustomDateRangePicker.swift create mode 100644 Modules/Sources/JetpackStats/Views/LegacyFloatingDateControl.swift create mode 100644 Modules/Sources/JetpackStats/Views/MetricsOverviewTabView.swift create mode 100644 Modules/Sources/JetpackStats/Views/SimpleErrorView.swift create mode 100644 Modules/Sources/JetpackStats/Views/StatsCardTitleView.swift create mode 100644 Modules/Sources/JetpackStats/Views/StatsDateRangePickerMenu.swift create mode 100644 Modules/Sources/JetpackStats/Views/StatsTabBar.swift create mode 100644 Modules/Sources/JetpackStats/Views/TopList/Rows/TopListAuthorRowView.swift create mode 100644 Modules/Sources/JetpackStats/Views/TopList/Rows/TopListExternalLinkRowView.swift create mode 100644 Modules/Sources/JetpackStats/Views/TopList/Rows/TopListLocationRowView.swift create mode 100644 Modules/Sources/JetpackStats/Views/TopList/Rows/TopListPostRowView.swift create mode 100644 Modules/Sources/JetpackStats/Views/TopList/Rows/TopListReferrerRowView.swift create mode 100644 Modules/Sources/JetpackStats/Views/TopList/TopListItemBarBackground.swift create mode 100644 Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift create mode 100644 Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift create mode 100644 Modules/Sources/JetpackStats/Views/TopList/TopListMetricsView.swift create mode 100644 Modules/Tests/JetpackStatsTests/CalendarNavigationTests.swift create mode 100644 Modules/Tests/JetpackStatsTests/CalendarPresetsTests.swift create mode 100644 Modules/Tests/JetpackStatsTests/DataPointTests.swift create mode 100644 Modules/Tests/JetpackStatsTests/DateIntervalTests.swift create mode 100644 Modules/Tests/JetpackStatsTests/DateRangeComparisonPeriodTests.swift create mode 100644 Modules/Tests/JetpackStatsTests/DateRangeGranularityTests.swift create mode 100644 Modules/Tests/JetpackStatsTests/MockStatsServiceTests.swift create mode 100644 Modules/Tests/JetpackStatsTests/StatsDataAggregationTests.swift create mode 100644 Modules/Tests/JetpackStatsTests/StatsDateFormatterTests.swift create mode 100644 Modules/Tests/JetpackStatsTests/StatsDateRangeFormatterTests.swift create mode 100644 Modules/Tests/JetpackStatsTests/StatsDateRangeTests.swift create mode 100644 Modules/Tests/JetpackStatsTests/StatsValueFormatterTests.swift create mode 100644 Modules/Tests/JetpackStatsTests/TestHelpers.swift create mode 100644 Modules/Tests/JetpackStatsTests/TrendViewModelTests.swift create mode 100644 WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift diff --git a/Modules/Package.resolved b/Modules/Package.resolved index a55dd61b0d48..c5b8f2ebadc6 100644 --- a/Modules/Package.resolved +++ b/Modules/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "729318612d504c093c0c0969353bb1df8e1a20bf7b46836e8885bb622196c02d", + "originHash" : "2d8453fbfe8c6681b647319f80bdc7e423722b43466750559095392aee0a8b16", "pins" : [ { "identity" : "alamofire", @@ -381,7 +381,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/wordpress-mobile/WordPressKit-iOS", "state" : { - "revision" : "ae3961ce89ac0c43a90e88d4963a04aa92008443" + "revision" : "ca75e34cee173868ca8e34348f8ff093e6ccf3b3" } }, { diff --git a/Modules/Package.swift b/Modules/Package.swift index 2d81c9f43459..58e5f3d88795 100644 --- a/Modules/Package.swift +++ b/Modules/Package.swift @@ -11,6 +11,7 @@ let package = Package( .library(name: "AsyncImageKit", targets: ["AsyncImageKit"]), .library(name: "DesignSystem", targets: ["DesignSystem"]), .library(name: "FormattableContentKit", targets: ["FormattableContentKit"]), + .library(name: "JetpackStats", targets: ["JetpackStats"]), .library(name: "JetpackStatsWidgetsCore", targets: ["JetpackStatsWidgetsCore"]), .library(name: "NotificationServiceExtensionCore", targets: ["NotificationServiceExtensionCore"]), .library(name: "ShareExtensionCore", targets: ["ShareExtensionCore"]), @@ -49,7 +50,7 @@ let package = Package( .package(url: "https://github.com/wordpress-mobile/NSURL-IDN", revision: "b34794c9a3f32312e1593d4a3d120572afa0d010"), .package( url: "https://github.com/wordpress-mobile/WordPressKit-iOS", - revision: "ae3961ce89ac0c43a90e88d4963a04aa92008443" // see wpios-edition branch + revision: "ca75e34cee173868ca8e34348f8ff093e6ccf3b3" // see wpios-edition branch ), .package(url: "https://github.com/zendesk/support_sdk_ios", from: "8.0.3"), // We can't use wordpress-rs branches nor commits here. Only tags work. @@ -93,6 +94,14 @@ let package = Package( // Set to v5 to avoid @Sendable warnings and errors swiftSettings: [.swiftLanguageMode(.v5)] ), + .target( + name: "JetpackStats", + dependencies: [ + "WordPressUI", + .product(name: "WordPressKit", package: "WordPressKit-iOS"), + ], + resources: [.process("Resources")] + ), .target(name: "JetpackStatsWidgetsCore", swiftSettings: [.swiftLanguageMode(.v5)]), .target( name: "ShareExtensionCore", @@ -171,6 +180,7 @@ let package = Package( dependencies: ["AsyncImageKit", "WordPressUI", "WordPressShared"], resources: [.process("Resources")] ), + .testTarget(name: "JetpackStatsTests", dependencies: ["JetpackStats"]), .testTarget(name: "JetpackStatsWidgetsCoreTests", dependencies: [.target(name: "JetpackStatsWidgetsCore")], swiftSettings: [.swiftLanguageMode(.v5)]), .testTarget(name: "DesignSystemTests", dependencies: [.target(name: "DesignSystem")], swiftSettings: [.swiftLanguageMode(.v5)]), .testTarget( @@ -276,6 +286,7 @@ enum XcodeSupport { "DesignSystem", "BuildSettingsKit", "FormattableContentKit", + "JetpackStats", "JetpackStatsWidgetsCore", "NotificationServiceExtensionCore", "SFHFKeychainUtils", diff --git a/Modules/Sources/JetpackStats/Cards/ChartCard.swift b/Modules/Sources/JetpackStats/Cards/ChartCard.swift new file mode 100644 index 000000000000..8a69dd3e87df --- /dev/null +++ b/Modules/Sources/JetpackStats/Cards/ChartCard.swift @@ -0,0 +1,224 @@ +import SwiftUI +import Charts + +struct ChartCard: View { + let metrics: [SiteMetric] + let dateRange: StatsDateRange + + @StateObject private var viewModel: ChartCardViewModel + + @State private var selectedMetric: SiteMetric + @State private var selectedChartType: ChartType = .line + @State private var isShowingRawData = false + + @ScaledMetric(relativeTo: .body) private var chartHeight = 180 + + init(metrics: [SiteMetric], dateRange: StatsDateRange, service: any StatsServiceProtocol) { + self.metrics = metrics + self.dateRange = dateRange + + assert(metrics.count > 0) + self._selectedMetric = .init(initialValue: metrics.first ?? .views) + + let viewModel = ChartCardViewModel(metrics: metrics, service: service) + self._viewModel = StateObject(wrappedValue: viewModel) + + viewModel.loadData(for: dateRange) + } + + var body: some View { + VStack(spacing: 0) { + VStack(spacing: 6) { + headerView(for: selectedMetric) + .unredacted() + contentView + } + .padding(Constants.step2) + + if metrics.count > 1 { + Divider() + footerView + } + } + .redacted(reason: viewModel.isFirstLoad ? .placeholder : []) + .overlay(alignment: .topTrailing) { + moreMenu + } + .grayscale(viewModel.isStale ? 1 : 0) + .animation(.smooth, value: viewModel.isStale) + .onChange(of: dateRange) { newRange in + viewModel.loadData(for: newRange) + } + } + + private func headerView(for metric: SiteMetric) -> some View { + HStack { + StatsCardTitleView(title: metric.localizedTitle, showChevron: false) + Spacer(minLength: 44) + } + } + + @ViewBuilder + private var contentView: some View { + Group { + if viewModel.isFirstLoad { + mainChartView(metric: selectedMetric, data: mockChartData) + } else if let chartData = viewModel.chartData[selectedMetric] { + mainChartView(metric: selectedMetric, data: chartData) + } else if let error = viewModel.loadingError { + mainChartView(metric: selectedMetric, data: mockChartData) + .redacted(reason: .placeholder) + .grayscale(1) + .opacity(0.66) + .overlay { + SimpleErrorView(error: error) + .background(Color(.systemBackground).opacity(0.9)) + .padding(-2) // Wasn't covering the chart well + } + } + } + .animation(.spring, value: selectedMetric) + .animation(.spring, value: selectedChartType) + } + + private var footerView: some View { + MetricsOverviewTabView( + data: viewModel.isFirstLoad ? viewModel.placeholderTabViewData : viewModel.tabViewData, + selectedMetric: $selectedMetric + ) + } + + private var mockChartData: ChartData { + ChartData.mock(metric: .views, granularity: dateRange.dateInterval.preferredGranularity, range: dateRange) + } + + // MARK: - Header View + + private var moreMenu: some View { + Menu { + moreMenuContent + } label: { + Image(systemName: "ellipsis") + .font(.body) + .foregroundColor(.secondary) + .frame(width: 50, height: 50) + } + .tint(Color.primary) + .sheet(isPresented: $isShowingRawData) { + NavigationStack { + ChartDataListView( + chartDataDict: viewModel.chartData, + selectedMetric: selectedMetric, + dateRanges: dateRange + ) + } + } + } + + @ViewBuilder + private var moreMenuContent: some View { + Section { + ControlGroup { + ForEach(ChartType.allCases, id: \.self) { type in + Button { + selectedChartType = type + } label: { + Label(type.localizedTitle, systemImage: type.systemImage) + } + } + } + } + Section { + Button { + // Not implemented + } label: { + Label(Strings.Buttons.share, systemImage: "square.and.arrow.up") + } + Button { + isShowingRawData = true + } label: { + Label(Strings.Chart.showData, systemImage: "tablecells") + } + } + } + + // MARK: - Chart View + + @ViewBuilder + private func mainChartView(metric: SiteMetric, data: ChartData) -> some View { + VStack(alignment: .leading, spacing: 8) { + // Showing currently selected (not loaded period) by design + ChartLegendView( + metric: metric, + currentPeriod: dateRange.dateInterval, + previousPeriod: dateRange.effectiveComparisonInterval + ) + .unredacted() + .padding(.bottom, 6) + .padding(.trailing, 20) + + ChartValuesSummaryView( + trend: TrendViewModel.make(data, context: .regular), + style: metrics.count > 1 ? .compact : .standard + ) + + chartContentView(data: data) + .frame(height: chartHeight) + .opacity(viewModel.isFirstLoad ? 0.33 : 1) + .transition(.push(from: .trailing).combined(with: .opacity).combined(with: .scale)) + } + } + + @ViewBuilder + private func chartContentView(data: ChartData) -> some View { + switch selectedChartType { + case .line: + LineChartView(data: data) + case .columns: + BarChartView(data: data) + } + } +} + +private enum ChartType: String, CaseIterable { + case line + case columns + + var localizedTitle: String { + switch self { + case .line: Strings.Chart.lineChart + case .columns: Strings.Chart.barChart + } + } + + var systemImage: String { + switch self { + case .line: "chart.line.uptrend.xyaxis" + case .columns: "chart.bar" + } + } +} + +// MARK: - Preview + +#Preview { + ScrollView { + VStack(spacing: 20) { + ChartCard( + metrics: [.views, .visitors, .likes, .comments], + dateRange: Calendar.demo.makeDateRange(for: .last7Days), + service: MockStatsService() + ) + .cardStyle() + + ChartCard( + metrics: [.timeOnSite, .bounceRate], + dateRange: Calendar.demo.makeDateRange(for: .last30Days), + service: MockStatsService() + ) + .cardStyle() + } + .padding(.vertical) + } + .background(Color(.systemGroupedBackground)) +} diff --git a/Modules/Sources/JetpackStats/Cards/ChartCardViewModel.swift b/Modules/Sources/JetpackStats/Cards/ChartCardViewModel.swift new file mode 100644 index 000000000000..b81de65d8a6c --- /dev/null +++ b/Modules/Sources/JetpackStats/Cards/ChartCardViewModel.swift @@ -0,0 +1,143 @@ +import SwiftUI + +@MainActor +final class ChartCardViewModel: ObservableObject { + @Published var chartData: [SiteMetric: ChartData] = [:] + @Published var isLoading = true + @Published var loadingError: Error? + @Published var isStale = false + + private let metrics: [SiteMetric] + private let service: any StatsServiceProtocol + + private var loadingTask: Task? + private var loadRequestCount = 0 + private var staleTimer: Task? + + var isFirstLoad: Bool { isLoading && chartData.isEmpty } + + init(metrics: [SiteMetric], service: any StatsServiceProtocol) { + self.metrics = metrics + self.service = service + } + + func loadData(for dateRange: StatsDateRange) { + loadingTask?.cancel() + staleTimer?.cancel() + + // Increment request count to track if this is the first request + loadRequestCount += 1 + let isFirstRequest = loadRequestCount == 1 + + // If we have data, start a timer to mark data as stale if there is + // no response in more than T seconds. + if !chartData.isEmpty { + staleTimer = Task { [weak self] in + try? await Task.sleep(for: .seconds(1)) + guard !Task.isCancelled else { return } + self?.isStale = true + } + } + + // Create a new loading task + loadingTask = Task { [weak self] in + guard let self else { return } + + // Add delay for subsequent requests to avoid rapid API calls when + // the user quickly switches between date intervals. + if !isFirstRequest { + try? await Task.sleep(for: .milliseconds(250)) + } + + guard !Task.isCancelled else { return } + await self.actuallyLoadData(dateRange: dateRange) + } + } + + private func actuallyLoadData(dateRange: StatsDateRange) async { + isLoading = true + loadingError = nil + + do { + try Task.checkCancellation() + + let data = try await getSiteStats(dateRange: dateRange) + + // Check for cancellation before updating the state + try Task.checkCancellation() + + // Cancel stale timer and reset stale flag when data is successfully loaded + staleTimer?.cancel() + isStale = false + chartData = data + } catch is CancellationError { + return + } catch { + loadingError = error + } + + loadRequestCount = 0 + isLoading = false + } + + private func getSiteStats(dateRange: StatsDateRange) async throws -> [SiteMetric: ChartData] { + var output: [SiteMetric: ChartData] = [:] + + let granularity = dateRange.dateInterval.preferredGranularity + + // Fetch both current and previous period data concurrently + async let currentResponseTask = service.getSiteStats( + interval: dateRange.dateInterval, + granularity: granularity + ) + async let previousResponseTask = service.getSiteStats( + interval: dateRange.effectiveComparisonInterval, + granularity: granularity + ) + + let (currentResponse, previousResponse) = try await (currentResponseTask, previousResponseTask) + + for (metric, dataPoints) in currentResponse.metrics { + let previousDataPoints = previousResponse.metrics[metric] ?? [] + + // Map previous data to align with current period dates + let mappedPreviousDataPoints = DataPoint.mapDataPoints( + previousDataPoints, + from: dateRange.effectiveComparisonInterval, + to: dateRange.dateInterval, + component: dateRange.component, + calendar: dateRange.calendar + ) + + output[metric] = ChartData( + metric: metric, + granularity: granularity, + currentData: dataPoints, + previousData: previousDataPoints, + mappedPreviousData: mappedPreviousDataPoints + ) + } + + return output + } + + var tabViewData: [MetricsOverviewTabView.MetricData] { + metrics.map { metric in + if let chartData = chartData[metric] { + return .init( + metric: metric, + value: chartData.currentTotal, + previousValue: chartData.previousTotal + ) + } else { + return .init(metric: metric, value: nil, previousValue: nil) + } + } + } + + var placeholderTabViewData: [MetricsOverviewTabView.MetricData] { + metrics.map { metric in + .init(metric: metric, value: 12345, previousValue: 11234) + } + } +} diff --git a/Modules/Sources/JetpackStats/Cards/RealtimeMetricsCard.swift b/Modules/Sources/JetpackStats/Cards/RealtimeMetricsCard.swift new file mode 100644 index 000000000000..aa85e09c8cc2 --- /dev/null +++ b/Modules/Sources/JetpackStats/Cards/RealtimeMetricsCard.swift @@ -0,0 +1,119 @@ +import SwiftUI + +struct RealtimeMetricsCard: View { + @State private var activeVisitors = 420 + @State private var visitorsLast30Min = 1280 + @State private var viewsLast30Min = 3720 + @State private var isPulsing = false + + let timer = Timer.publish(every: 3, on: .main, in: .common).autoconnect() + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 0) { + Text("Realtime") + .font(.headline) + .foregroundColor(.primary) + Circle() + .fill(Color.green) + .frame(width: 6, height: 6) + .scaleEffect(isPulsing ? 1.2 : 0.8) + .opacity(isPulsing ? 0.4 : 0.8) + .animation(.easeInOut(duration: 2.0).repeatForever(autoreverses: true), value: isPulsing) + .padding(.leading, 8) + + Spacer() + } + + Text("Last 30 minutes") + .font(.subheadline.smallCaps()).fontWeight(.medium) + .foregroundColor(.secondary) + } + + HStack(spacing: 16) { + realtimeStatRow( + systemImage: SiteMetric.views.systemImage, + label: SiteMetric.views.localizedTitle, + value: viewsLast30Min.formatted(.number.notation(.compactName)) + ) + + realtimeStatRow( + systemImage: SiteMetric.visitors.systemImage, + label: SiteMetric.visitors.localizedTitle, + value: visitorsLast30Min.formatted(.number.notation(.compactName)) + ) + + realtimeStatRow( + systemImage: SiteMetric.visitors.systemImage, + label: Strings.SiteMetrics.visitorsNow, + value: activeVisitors.formatted(.number.notation(.compactName)) + ) + } + } + .padding() + .onAppear { + isPulsing = true + } + .onReceive(timer) { _ in + updateRealtimeStats() + } + } + + private func realtimeStatRow(systemImage: String, label: String, value: String) -> some View { + VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 4) { + Image(systemName: systemImage) + .font(.caption2.weight(.medium)) + .foregroundColor(.secondary) + + Text(label.uppercased()) + .font(.caption.weight(.medium)) + .foregroundColor(.secondary) + } + + Text(value) + .contentTransition(.numericText()) + .animation(.spring, value: value) + .font(Font.make(.recoleta, textStyle: .title, weight: .medium)) + .foregroundColor(.primary) + } + .frame(minWidth: 100, alignment: .leading) + } + + private func updateRealtimeStats() { + withAnimation(.easeInOut(duration: 0.3)) { + // Active visitors: typically 300-600 with small variations + let activeVariation = Int.random(in: -30...30) + activeVisitors = max(100, min(800, activeVisitors + activeVariation)) + + // Visitors in last 30 min: typically 1000-2000, smoother changes + let visitorVariation = Int.random(in: -50...80) + visitorsLast30Min = max(500, min(3000, visitorsLast30Min + visitorVariation)) + + // Views in last 30 min: typically 3000-5000, more volatile + let viewsVariation = Int.random(in: -150...200) + viewsLast30Min = max(1000, min(8000, viewsLast30Min + viewsVariation)) + + // Occasionally simulate traffic spikes (5% chance) + if Int.random(in: 1...20) == 1 { + activeVisitors += Int.random(in: 50...150) + visitorsLast30Min += Int.random(in: 200...400) + viewsLast30Min += Int.random(in: 500...1000) + } + + // Occasionally simulate traffic drops (5% chance) + if Int.random(in: 1...20) == 20 { + activeVisitors = max(100, activeVisitors - Int.random(in: 50...100)) + visitorsLast30Min = max(500, visitorsLast30Min - Int.random(in: 100...300)) + viewsLast30Min = max(1000, viewsLast30Min - Int.random(in: 300...600)) + } + } + } +} + +#Preview { + RealtimeMetricsCard() + .padding() + .background(Constants.Colors.statsBackground) +} diff --git a/Modules/Sources/JetpackStats/Cards/RealtimeTopListCard.swift b/Modules/Sources/JetpackStats/Cards/RealtimeTopListCard.swift new file mode 100644 index 000000000000..c84a9689f971 --- /dev/null +++ b/Modules/Sources/JetpackStats/Cards/RealtimeTopListCard.swift @@ -0,0 +1,168 @@ +import SwiftUI + +struct RealtimeTopListCard: View { + let availableItems: [TopListItemType] + + @StateObject private var viewModel: RealtimeTopListCardViewModel + @State private var selectedItem: TopListItemType + + @Environment(\.context) var context + + init( + availableDataTypes: [TopListItemType] = TopListItemType.allCases, + initialDataType: TopListItemType = .postsAndPages, + service: any StatsServiceProtocol + ) { + self.availableItems = availableDataTypes + + let selectedItem = availableDataTypes.contains(initialDataType) ? initialDataType : availableDataTypes.first ?? .postsAndPages + self._selectedItem = State(initialValue: selectedItem) + + let viewModel = RealtimeTopListCardViewModel(service: service) + self._viewModel = StateObject(wrappedValue: viewModel) + + viewModel.loadData(for: selectedItem) + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + StatsCardTitleView(title: selectedItem.getTitle(for: .views)) + .unredacted() + Spacer() + } + VStack(spacing: 12) { + headerView + .unredacted() + contentView + } + } + .padding() + .redacted(reason: viewModel.isFirstLoad ? .placeholder : []) + .onChange(of: selectedItem) { newValue in + viewModel.loadData(for: newValue) + } + } + + + private var headerView: some View { + HStack { + Menu { + ForEach(availableItems) { dataType in + Button { + // Temporarily solution while there are animation isseus with Menu on iOS 26 + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { + selectedItem = dataType + } + } label: { + Label(dataType.localizedTitle, systemImage: dataType.systemImage) + } + } + .tint(Color.primary) + } label: { + InlineValuePickerTitle(title: selectedItem.localizedTitle) + } + .fixedSize() + + Spacer() + + Text("Views") + .font(.subheadline.weight(.medium)) + .foregroundStyle(.secondary) + } + } + + @ViewBuilder + private var contentView: some View { + Group { + if viewModel.isFirstLoad { + loadingView + } else if let data = viewModel.topListData { + topListItemsView(data: data) + } else if let error = viewModel.loadingError { + loadingView + .redacted(reason: .placeholder) + .overlay { + SimpleErrorView(error: error) + .background(Color(.systemBackground)) + } + } + } + .animation(.spring, value: selectedItem) + } + + private func topListItemsView(data: TopListData) -> some View { + let chartData = TopListChartData( + item: selectedItem, + metric: .views, + items: data.items.map { TopListChartData.Item(current: $0, previous: nil) }, + maxValue: viewModel.maxValue, + ) + + return TopListItemsView( + data: chartData, + itemLimit: 6, + showDetails: false, + showMoreButton: data.items.count > 6 || viewModel.isFirstLoad, + onShowMore: { + // Show more action + } + ) + } + + + + private var loadingView: some View { + topListItemsView(data: mockData) + } + + private var mockData: TopListData { + let chartData = TopListChartData.mock( + for: selectedItem, + metric: .views, + itemCount: 6 + ) + return TopListData(items: chartData.items.map { $0.current }) + } + +} + +// MARK: - Preview + +#Preview { + ScrollView { + VStack(spacing: 20) { + // Posts & Pages + RealtimeTopListCard( + availableDataTypes: [.postsAndPages, .posts, .pages], + initialDataType: .postsAndPages, + service: MockStatsService() + ) + .padding() + .background(Color(.systemBackground)) + .cornerRadius(12) + + // Referrers + RealtimeTopListCard( + availableDataTypes: [.referrers], + initialDataType: .referrers, + service: MockStatsService() + ) + .padding() + .background(Color(.systemBackground)) + .cornerRadius(12) + + // Locations + RealtimeTopListCard( + availableDataTypes: [.locations], + initialDataType: .locations, + service: MockStatsService() + ) + .padding() + .background(Color(.systemBackground)) + .cornerRadius(12) + } + .padding() + } + .background(Color(.systemGroupedBackground)) +} diff --git a/Modules/Sources/JetpackStats/Cards/RealtimeTopListCardViewModel.swift b/Modules/Sources/JetpackStats/Cards/RealtimeTopListCardViewModel.swift new file mode 100644 index 000000000000..f26de6e3260f --- /dev/null +++ b/Modules/Sources/JetpackStats/Cards/RealtimeTopListCardViewModel.swift @@ -0,0 +1,66 @@ +import SwiftUI + +@MainActor +final class RealtimeTopListCardViewModel: ObservableObject { + @Published var topListData: TopListData? + @Published var isLoading = true + @Published var loadingError: Error? + + private let service: any StatsServiceProtocol + private var realtimeTimer: Task? + private var currentDataType: TopListItemType? + + var isFirstLoad: Bool { isLoading && topListData == nil } + + init(service: any StatsServiceProtocol) { + self.service = service + startRealtimeUpdates() + } + + deinit { + realtimeTimer?.cancel() + } + + func loadData(for dataType: TopListItemType) { + currentDataType = dataType + + Task { + await actuallyLoadData(dataType: dataType) + } + } + + private func actuallyLoadData(dataType: TopListItemType) async { + isLoading = true + loadingError = nil + + do { + let response = try await service.getRealtimeTopListData(dataType) + topListData = response + } catch { + loadingError = error + topListData = nil + } + + isLoading = false + } + + var maxValue: Int { + guard let data = topListData else { return 1 } + let values = data.items.compactMap { $0.metrics[.views] } + return values.max() ?? 1 + } + + private func startRealtimeUpdates() { + realtimeTimer = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(2)) + guard let self, !Task.isCancelled else { break } + + // Trigger a reload with the current state + if let dataType = self.currentDataType { + await self.actuallyLoadData(dataType: dataType) + } + } + } + } +} diff --git a/Modules/Sources/JetpackStats/Cards/TopListCard.swift b/Modules/Sources/JetpackStats/Cards/TopListCard.swift new file mode 100644 index 000000000000..929270c8dad5 --- /dev/null +++ b/Modules/Sources/JetpackStats/Cards/TopListCard.swift @@ -0,0 +1,208 @@ +import SwiftUI + +struct TopListCard: View { + let dateRange: StatsDateRange + let availableItems: [TopListItemType] + + @StateObject private var viewModel: TopListCardViewModel + @State private var selectedItem: TopListItemType + @State private var selectedMetric: SiteMetric = .views + + private let itemLimit = 6 + + @Environment(\.context) var context + + init( + dateRange: StatsDateRange, + availableDataTypes: [TopListItemType] = TopListItemType.allCases, + initialDataType: TopListItemType = .postsAndPages, + service: any StatsServiceProtocol + ) { + self.dateRange = dateRange + self.availableItems = availableDataTypes + + let selectedItem = availableDataTypes.contains(initialDataType) ? initialDataType : availableDataTypes.first ?? .postsAndPages + self._selectedItem = State(initialValue: selectedItem) + + let viewModel = TopListCardViewModel(service: service) + self._viewModel = StateObject(wrappedValue: viewModel) + + viewModel.setSelectedMetric(.views) + viewModel.loadData(for: selectedItem, dateRange: self.dateRange, metric: .views) + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack { + StatsCardTitleView(title: selectedItem.getTitle(for: selectedMetric)) + Spacer(minLength: 44) + } + VStack(spacing: 12) { + headerView + contentView + } + } + .padding(Constants.step2) + .overlay(alignment: .topTrailing) { + moreMenu + } + .grayscale(viewModel.isStale ? 1 : 0) + .animation(.smooth, value: viewModel.isStale) + .onChange(of: selectedItem) { newValue in + // Reset to views when data type changes, as not all metrics are available for all types + selectedMetric = .views + viewModel.loadData(for: newValue, dateRange: dateRange, metric: selectedMetric) + } + .onChange(of: selectedMetric) { _ in + viewModel.setSelectedMetric(selectedMetric) + viewModel.loadData(for: selectedItem, dateRange: dateRange, metric: selectedMetric) + } + .onChange(of: dateRange) { _ in + viewModel.loadData(for: selectedItem, dateRange: dateRange, metric: selectedMetric) + } + } + + private var headerView: some View { + HStack { + Menu { + ForEach(availableItems) { dataType in + Button { + selectedItem = dataType + } label: { + Label(dataType.localizedTitle, systemImage: dataType.systemImage) + } + } + .tint(Color.primary) + } label: { + InlineValuePickerTitle(title: selectedItem.localizedTitle) + } + .fixedSize() + + Spacer() + + Menu { + ForEach(selectedItem.availableMetrics) { metric in + Button { + selectedMetric = metric + } label: { + Label(metric.localizedTitle, systemImage: metric.systemImage) + } + } + .tint(Color.primary) + } label: { + InlineValuePickerTitle(title: selectedMetric.localizedTitle) + } + .fixedSize() + } + } + + private var moreMenu: some View { + Menu { + moreMenuContent + } label: { + Image(systemName: "ellipsis") + .font(.body) + .foregroundColor(.secondary) + .frame(width: 50, height: 50) + } + .tint(Color.primary) + } + + @ViewBuilder + private var moreMenuContent: some View { + Section { + Button { + // Not implemented + } label: { + Label(Strings.Buttons.share, systemImage: "square.and.arrow.up") + } + } + } + + @ViewBuilder + private var contentView: some View { + Group { + if viewModel.isFirstLoad { + topListItemsView(data: mockData) + .redacted(reason: .placeholder) + } else if let data = viewModel.matchedData { + topListItemsView(data: data) + } else if let error = viewModel.loadingError { + topListItemsView(data: mockData) + .redacted(reason: .placeholder) + .grayscale(1) + .opacity(0.8) + .overlay { + SimpleErrorView(error: error) + .background(Color(.systemBackground).opacity(0.66)) + } + } + } + } + + private func topListItemsView(data: TopListChartData) -> some View { + TopListItemsView( + data: data, + itemLimit: itemLimit, + showDetails: true, + showMoreButton: true, + onShowMore: { + // Not implemented + } + ) + } + + private var mockData: TopListChartData { + TopListChartData.mock(for: selectedItem, metric: selectedMetric, itemCount: itemLimit) + } +} + +// MARK: - Preview + +#Preview { + ScrollView { + VStack(spacing: 20) { + // Posts & Pages + TopListCard( + dateRange: Calendar.demo.makeDateRange(for: .last7Days), + availableDataTypes: [.postsAndPages, .posts, .pages], + initialDataType: .postsAndPages, + service: MockStatsService() + ) + .background(Color(.systemBackground)) + .cornerRadius(12) + + // Referrers + TopListCard( + dateRange: Calendar.demo.makeDateRange(for: .last30Days), + availableDataTypes: [.referrers], + initialDataType: .referrers, + service: MockStatsService() + ) + .background(Color(.systemBackground)) + .cornerRadius(12) + + // Locations + TopListCard( + dateRange: Calendar.demo.makeDateRange(for: .last30Days), + availableDataTypes: [.locations], + initialDataType: .locations, + service: MockStatsService() + ) + .background(Color(.systemBackground)) + .cornerRadius(12) + + // Authors + TopListCard( + dateRange: Calendar.demo.makeDateRange(for: .last30Days), + availableDataTypes: [.authors], + initialDataType: .authors, + service: MockStatsService() + ) + .background(Color(.systemBackground)) + .cornerRadius(12) + } + .padding() + } + .background(Color(.systemGroupedBackground)) +} diff --git a/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift b/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift new file mode 100644 index 000000000000..2cf8c194eaa1 --- /dev/null +++ b/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift @@ -0,0 +1,125 @@ +import SwiftUI + +@MainActor +final class TopListCardViewModel: ObservableObject { + @Published var matchedData: TopListChartData? + @Published var isLoading = true + @Published var loadingError: Error? + @Published var isStale = false + + private let service: any StatsServiceProtocol + + private var loadingTask: Task? + private var loadRequestCount = 0 + private var staleTimer: Task? + + var isFirstLoad: Bool { isLoading && matchedData == nil } + + init(service: any StatsServiceProtocol) { + self.service = service + } + + func loadData(for item: TopListItemType, dateRange: StatsDateRange, metric: SiteMetric) { + loadingTask?.cancel() + staleTimer?.cancel() + + // Increment request count to track if this is the first request + loadRequestCount += 1 + let isFirstRequest = loadRequestCount == 1 + + // If we have data, start a timer to mark data as stale if there is + // no response in more than T seconds. + if matchedData != nil { + staleTimer = Task { [weak self] in + try? await Task.sleep(for: .seconds(1)) + guard !Task.isCancelled else { return } + self?.isStale = true + } + } + + // Create a new loading task + loadingTask = Task { [weak self] in + guard let self else { return } + + // Add delay for subsequent requests to avoid rapid API calls when + // the user quickly switches between data types or metrics. + if !isFirstRequest { + try? await Task.sleep(for: .milliseconds(250)) + } + + guard !Task.isCancelled else { return } + self.selectedMetric = metric + await self.actuallyLoadData(for: item, dateRange: dateRange, metric: metric) + } + } + + private func actuallyLoadData(for item: TopListItemType, dateRange: StatsDateRange, metric: SiteMetric) async { + isLoading = true + loadingError = nil + + do { + try Task.checkCancellation() + + let data = try await getTopListData(for: item, dateRange: dateRange) + + // Check for cancellation before updating the state + try Task.checkCancellation() + + // Cancel stale timer and reset stale flag when data is successfully loaded + staleTimer?.cancel() + isStale = false + + matchedData = data + } catch is CancellationError { + return + } catch { + loadingError = error + matchedData = nil + } + + loadRequestCount = 0 + isLoading = false + } + + private func getTopListData(for item: TopListItemType, dateRange: StatsDateRange) async throws -> TopListChartData { + let granularity = dateRange.dateInterval.preferredGranularity + + // Fetch both current and previous period data concurrently + async let currentTask = service.getTopListData( + item, + range: dateRange.dateInterval, + granularity: granularity + ) + async let previousTask = service.getTopListData( + item, + range: dateRange.effectiveComparisonInterval, + granularity: granularity + ) + + let (current, previous) = try await (currentTask, previousTask) + + // Match current items with their previous counterparts + let matchedItems = current.items.map { currentItem in + let previousItem = previous.items.first { $0.id == currentItem.id } + return TopListChartData.Item(current: currentItem, previous: previousItem) + } + + // Calculate max value from current items based on selected metric + let metric = selectedMetric ?? .views + let maxValue = current.items + .compactMap { $0.metrics[metric] } + .max() ?? 1 + + return TopListChartData(item: item, metric: metric, items: matchedItems, maxValue: maxValue) + } + + var maxValue: Int { + matchedData?.maxValue ?? 1 + } + + private var selectedMetric: SiteMetric? + + func setSelectedMetric(_ metric: SiteMetric) { + selectedMetric = metric + } +} diff --git a/Modules/Sources/JetpackStats/Charts/BarChartView.swift b/Modules/Sources/JetpackStats/Charts/BarChartView.swift new file mode 100644 index 000000000000..f07ef8aa8750 --- /dev/null +++ b/Modules/Sources/JetpackStats/Charts/BarChartView.swift @@ -0,0 +1,170 @@ +import SwiftUI +import Charts + +struct BarChartView: View { + let data: ChartData + + @State private var selectedDate: Date? + @State private var selectedDataPoints: SelectedDataPoints? + + @Environment(\.context) var context + + private var valueFormatter: StatsValueFormatter { + StatsValueFormatter(metric: data.metric) + } + + var body: some View { + Chart { + previousPeriodBars + currentPeriodBars + selectionIndicatorMarks + } + .chartXAxis { xAxis } + .chartYAxis { yAxis } + .chartLegend(.hidden) + .modifier(ChartSelectionModifier(selection: $selectedDate)) + .animation(.spring, value: ObjectIdentifier(data)) + .onChange(of: selectedDate) { + selectedDataPoints = SelectedDataPoints.compute(for: $0, data: data) + } + + } + + // MARK: - Chart Marks + + @ChartContentBuilder + private var currentPeriodBars: some ChartContent { + ForEach(data.currentData) { point in + BarMark( + x: .value("Date", point.date, unit: data.granularity.component), + y: .value("Value", point.value), + width: .ratio(0.75) + ) + .foregroundStyle(data.metric.primaryColor) + .cornerRadius(4) + .opacity(getOpacityForCurrentPeriodBar(for: point)) + } + } + + private func getOpacityForCurrentPeriodBar(for point: DataPoint) -> CGFloat { + guard let selectedDataPoints else { + let isIncomplete = context.calendar.isIncompleteDataPeriod(for: point.date, granularity: data.granularity) + return isIncomplete ? 0.5 : 1 + } + return selectedDataPoints.current?.id == point.id ? 1.0 : 0.5 + } + + @ChartContentBuilder + private var previousPeriodBars: some ChartContent { + ForEach(data.mappedPreviousData) { point in + BarMark( + x: .value("Date", point.date, unit: data.granularity.component), + y: .value("Value", point.value), + width: .ratio(0.75), + stacking: .unstacked + ) + .foregroundStyle(Color.secondary) + .cornerRadius(4) + .opacity(shouldHighlightPreviousDataPoint(point) ? 0.5 : 0.2) + } + } + + private func shouldHighlightPreviousDataPoint(_ dataPoint: DataPoint) -> Bool { + guard let selectedDataPoints else { + return false + } + return selectedDataPoints.current == nil && selectedDataPoints.previous?.id == dataPoint.id + } + + @ChartContentBuilder + private var selectionIndicatorMarks: some ChartContent { + if #available(iOS 17.0, *), + let selectedDate, + let _ = selectedDataPoints { + RuleMark(x: .value("Selected", selectedDate)) + .foregroundStyle(Color.clear) + .lineStyle(StrokeStyle(lineWidth: 1)) + .offset(yStart: 32) + .zIndex(3) + .annotation( + position: .top, + spacing: 0, + overflowResolution: .init( + x: .fit(to: .chart), + y: .disabled + ) + ) { + tooltipView + } + } + } + + // MARK: - Axis Configuration + + private var xAxis: some AxisContent { + AxisMarks { value in + if let date = value.as(Date.self) { + AxisValueLabel { + ChartAxisDateLabel(date: date, granularity: data.granularity) + } + } + } + } + + private var yAxis: some AxisContent { + AxisMarks(values: .automatic) { value in + if let value = value.as(Int.self) { + AxisGridLine() + .foregroundStyle(Color.secondary.opacity(0.33)) + AxisValueLabel { + if value > 0 { + Text(valueFormatter.format(value: value, context: .compact)) + .font(.caption2.weight(.medium)) + .foregroundColor(.secondary) + } + } + } + } + } + + // MARK: - Helper Views + + @ViewBuilder + private var tooltipView: some View { + if let selectedPoints = selectedDataPoints { + ChartValueTooltipView( + currentPoint: selectedPoints.current, + previousPoint: selectedPoints.previous, + metric: data.metric, + granularity: data.granularity + ) + } + } +} + +// MARK: - Preview + +#Preview { + VStack(spacing: 20) { + BarChartView( + data: ChartData.mock( + metric: .visitors, + granularity: .day, + range: Calendar.demo.makeDateRange(for: .last7Days) + ) + ) + .frame(height: 250) + .padding() + + BarChartView( + data: ChartData.mock( + metric: .likes, + granularity: .month, + range: Calendar.demo.makeDateRange(for: .thisYear) + ) + ) + .frame(height: 250) + .padding() + } + .background(Color(.systemGroupedBackground)) +} diff --git a/Modules/Sources/JetpackStats/Charts/ChartData.swift b/Modules/Sources/JetpackStats/Charts/ChartData.swift new file mode 100644 index 000000000000..de84b7a728b0 --- /dev/null +++ b/Modules/Sources/JetpackStats/Charts/ChartData.swift @@ -0,0 +1,80 @@ +import SwiftUI + +final class ChartData { + let metric: SiteMetric + let granularity: DateRangeGranularity + let currentData: [DataPoint] + let previousData: [DataPoint] + let mappedPreviousData: [DataPoint] + + lazy private(set) var currentTotal = DataPoint.getTotalValue(for: currentData, metric: metric) + lazy private(set) var previousTotal = DataPoint.getTotalValue(for: previousData, metric: metric) + + init(metric: SiteMetric, granularity: DateRangeGranularity, currentData: [DataPoint], previousData: [DataPoint], mappedPreviousData: [DataPoint]) { + self.metric = metric + self.granularity = granularity + self.currentData = currentData + self.previousData = previousData + self.mappedPreviousData = mappedPreviousData + } +} + +// MARK: - Placeholder Data + +extension ChartData { + static func mock(metric: SiteMetric, granularity: DateRangeGranularity, range: StatsDateRange) -> ChartData { + let dataPoints = generateMockDataPoints( + granularity: granularity, + range: range, + metric: metric + ) + let previousData = dataPoints.map { dataPoint in + let variation = Double.random(in: 0.75...0.95) + return DataPoint( + date: dataPoint.date, + value: Int(Double(dataPoint.value) * variation) + ) + } + return ChartData( + metric: metric, + granularity: granularity, + currentData: dataPoints, + previousData: previousData, + mappedPreviousData: previousData + ) + } + + private static func generateMockDataPoints( + granularity: DateRangeGranularity, + range: StatsDateRange, + metric: SiteMetric + ) -> [DataPoint] { + let calendar = range.calendar + var dataPoints: [DataPoint] = [] + + let valueRange = valueRange(for: metric) + + // Generate data points for each component in the range + var currentDate = range.dateInterval.start + while currentDate < range.dateInterval.end { + let value = Int.random(in: valueRange) + dataPoints.append(DataPoint(date: currentDate, value: value)) + + guard let nextDate = calendar.date(byAdding: granularity.component, value: 1, to: currentDate) else { break } + currentDate = nextDate + } + + return dataPoints + } + + private static func valueRange(for metric: SiteMetric) -> ClosedRange { + switch metric { + case .views: 1000...5000 + case .visitors: 500...2500 + case .likes: 50...300 + case .comments: 10...100 + case .timeOnSite: 120...300 + case .bounceRate: 40...80 + } + } +} diff --git a/Modules/Sources/JetpackStats/Charts/LineChartView.swift b/Modules/Sources/JetpackStats/Charts/LineChartView.swift new file mode 100644 index 000000000000..5333a37928d5 --- /dev/null +++ b/Modules/Sources/JetpackStats/Charts/LineChartView.swift @@ -0,0 +1,223 @@ +import SwiftUI +import Charts + +struct LineChartView: View { + let data: ChartData + + @State private var selectedDate: Date? + @State private var selectedDataPoints: SelectedDataPoints? + + @Environment(\.colorScheme) var colorScheme + @Environment(\.context) var context + + private var valueFormatter: StatsValueFormatter { + StatsValueFormatter(metric: data.metric) + } + + private var shouldShowCurrentTimeBoundary: Bool { + guard let lastDataPoint = data.currentData.last, + let firstDataPoint = data.currentData.first, + let lastDate = data.currentData.last?.date else { + return false + } + return context.calendar.isDateInToday(lastDate) || (firstDataPoint.date...lastDataPoint.date).contains(.now) + } + + var body: some View { + Chart { + currentPeriodMarks + previousPeriodMarks + currentTimeBoundaryMark + selectionIndicatorMarks + } + .chartXAxis { xAxis } + .chartYAxis { yAxis } + .chartLegend(.hidden) + .modifier(ChartSelectionModifier(selection: $selectedDate)) + .animation(.spring, value: ObjectIdentifier(data)) + .onChange(of: selectedDate) { + selectedDataPoints = SelectedDataPoints.compute(for: $0, data: data) + } + } + + // MARK: - Chart Marks + + @ChartContentBuilder + private var currentPeriodMarks: some ChartContent { + ForEach(data.currentData) { point in + AreaMark( + x: .value("Date", point.date), + y: .value("Value", point.value), + series: .value("Period", "Current") + ) + .foregroundStyle(data.metric.backgroundColor(in: colorScheme)) + .interpolationMethod(.linear) + + LineMark( + x: .value("Date", point.date), + y: .value("Value", point.value), + series: .value("Period", "Current") + ) + .foregroundStyle(data.metric.primaryColor) + .lineStyle(StrokeStyle( + lineWidth: 3, + lineCap: .round, + lineJoin: .round + )) + .interpolationMethod(.linear) + } + } + + @ChartContentBuilder + private var previousPeriodMarks: some ChartContent { + ForEach(data.mappedPreviousData) { point in + // Important: AreaMark is needed for smooth animation + AreaMark( + x: .value("Date", point.date), + y: .value("Value", point.value), + series: .value("Period", "Previous") + ) + .foregroundStyle(Color.clear) + .interpolationMethod(.linear) + + LineMark( + x: .value("Date", point.date), + y: .value("Value", point.value), + series: .value("Period", "Previous") + ) + .foregroundStyle(Color.secondary.opacity(0.8)) + .lineStyle(StrokeStyle( + lineWidth: 2, + lineCap: .round, + lineJoin: .round, + dash: [5, 6] + )) + .interpolationMethod(.linear) + } + } + + @ChartContentBuilder + private var currentTimeBoundaryMark: some ChartContent { + if shouldShowCurrentTimeBoundary, + let lastDataPoint = data.currentData.last { + RuleMark( + x: .value("Now", lastDataPoint.date), + yStart: .value("Start", 0), + yEnd: .value("End", lastDataPoint.value) + ) + .foregroundStyle(data.metric.primaryColor.opacity(0.33)) + .lineStyle(StrokeStyle( + lineWidth: 2, + lineCap: .round, + dash: [5, 5] + )) + } + } + + @ChartContentBuilder + private var selectionIndicatorMarks: some ChartContent { + if #available(iOS 17.0, *), + let selectedDate, + let selectedPoints = selectedDataPoints { + + RuleMark(x: .value("Selected", selectedDate)) + .foregroundStyle(Color.secondary.opacity(0.33)) + .lineStyle(StrokeStyle(lineWidth: 1)) + .offset(yStart: 28) + .zIndex(1) + .annotation( + position: .top, + spacing: 0, + overflowResolution: .init(x: .fit(to: .chart), y: .disabled) + ) { + tooltipView + } + + if let currentPoint = selectedPoints.current { + PointMark( + x: .value("Date", currentPoint.date), + y: .value("Value", currentPoint.value) + ) + .foregroundStyle(data.metric.primaryColor) + .symbolSize(80) + } + + if let previousPoint = selectedPoints.previous { + PointMark( + x: .value("Date", previousPoint.date), + y: .value("Value", previousPoint.value) + ) + .foregroundStyle(Color.secondary) + .symbolSize(60) + } + } + } + + // MARK: - Axis Configuration + + private var xAxis: some AxisContent { + AxisMarks { value in + if let date = value.as(Date.self) { + AxisValueLabel { + ChartAxisDateLabel(date: date, granularity: data.granularity) + } + } + } + } + + private var yAxis: some AxisContent { + AxisMarks { value in + if let value = value.as(Int.self) { + AxisGridLine() + .foregroundStyle(Color(.opaqueSeparator).opacity(0.5)) + + AxisValueLabel { + Text(valueFormatter.format(value: value, context: .compact)) + .font(.caption2.weight(.medium)) + .foregroundColor(.secondary) + } + } + } + } + + // MARK: - Helper Views + + @ViewBuilder + private var tooltipView: some View { + if let selectedPoints = selectedDataPoints { + ChartValueTooltipView( + currentPoint: selectedPoints.current, + previousPoint: selectedPoints.previous, + metric: data.metric, + granularity: data.granularity + ) + } + } +} + +// MARK: - Preview + +#Preview { + VStack(spacing: 20) { + LineChartView( + data: ChartData.mock( + metric: .views, + granularity: .day, + range: Calendar.demo.makeDateRange(for: .last7Days) + ) + ) + .frame(height: 250) + .padding() + + LineChartView( + data: ChartData.mock( + metric: .timeOnSite, + granularity: .month, + range: Calendar.demo.makeDateRange(for: .thisYear) + ) + ) + .frame(height: 250) + .padding() + } + .background(Color(.systemGroupedBackground)) +} diff --git a/Modules/Sources/JetpackStats/Constants.swift b/Modules/Sources/JetpackStats/Constants.swift new file mode 100644 index 000000000000..533e4d817fb2 --- /dev/null +++ b/Modules/Sources/JetpackStats/Constants.swift @@ -0,0 +1,48 @@ +import SwiftUI +import ColorStudio + +enum Constants { + enum Colors { + static let positiveChangeForeground = Color(UIColor( + light: UIColor(red: 0.2, green: 0.6, blue: 0.2, alpha: 1.0), + dark: UIColor(red: 0.4, green: 0.8, blue: 0.4, alpha: 1.0) + )) + + static let negativeChangeForeground = Color(UIColor( + light: UIColor(red: 0.7, green: 0.3, blue: 0.3, alpha: 1.0), + dark: UIColor(red: 0.9, green: 0.5, blue: 0.5, alpha: 1.0) + )) + + static let positiveChangeBackground = Color(UIColor( + light: UIColor(red: 0.9, green: 0.95, blue: 0.9, alpha: 1.0), + dark: UIColor(red: 0.15, green: 0.3, blue: 0.15, alpha: 1.0) + )) + + static let negativeChangeBackground = Color(UIColor( + light: UIColor(red: 0.95, green: 0.9, blue: 0.9, alpha: 1.0), + dark: UIColor(red: 0.3, green: 0.15, blue: 0.15, alpha: 1.0) + )) + + static let statsBackground = Color(UIColor( + light: CSColor.Gray.shade(.shade0), + dark: UIColor.systemBackground + )) + + static let blue = Color(palette: CSColor.Blue.self) + static let purple = Color(palette: CSColor.Purple.self) + static let red = Color(palette: CSColor.Red.self) + static let green = Color(palette: CSColor.Green.self) + static let orange = Color(palette: CSColor.Orange.self) + static let pink = Color(palette: CSColor.Pink.self) + } + + static let step1: CGFloat = 12 + static let step2: CGFloat = 18 + static let step3: CGFloat = 24 +} + +private extension Color { + init(palette: T.Type) { + self.init(uiColor: UIColor(light: T.shade(.shade50), dark: T.shade(.shade40))) + } +} diff --git a/Modules/Sources/JetpackStats/Extensions/Calendar+ComparisonPeriod.swift b/Modules/Sources/JetpackStats/Extensions/Calendar+ComparisonPeriod.swift new file mode 100644 index 000000000000..f56b8abd6a84 --- /dev/null +++ b/Modules/Sources/JetpackStats/Extensions/Calendar+ComparisonPeriod.swift @@ -0,0 +1,38 @@ +import Foundation + +enum DateRangeComparisonPeriod: Equatable, Sendable, CaseIterable, Identifiable { + case precedingPeriod + case samePeriodLastYear + + var id: DateRangeComparisonPeriod { self } + + var localizedTitle: String { + switch self { + case .precedingPeriod: Strings.DatePicker.precedingPeriod + case .samePeriodLastYear: Strings.DatePicker.samePeriodLastYear + } + } +} + +extension Calendar { + func comparisonRange(for dateInterval: DateInterval, period: DateRangeComparisonPeriod, component: Calendar.Component) -> DateInterval { + switch period { + case .precedingPeriod: + return navigate(dateInterval, direction: .backward, component: component) + case .samePeriodLastYear: + guard let newStart = date(byAdding: .year, value: -1, to: dateInterval.start), + let newEnd = date(byAdding: .year, value: -1, to: dateInterval.end) else { + assertionFailure("something went wrong: invalid range for: \(dateInterval)") + return dateInterval + } + return DateInterval(start: newStart, end: newEnd) + } + } + + /// Determines if a date represents a period that might have incomplete data. + /// + /// - Returns: True if the date's period might have incomplete data + func isIncompleteDataPeriod(for date: Date, granularity: DateRangeGranularity, now: Date = .now) -> Bool { + isDate(date, equalTo: now, toGranularity: granularity.component) + } +} diff --git a/Modules/Sources/JetpackStats/Extensions/Calendar+Navigation.swift b/Modules/Sources/JetpackStats/Extensions/Calendar+Navigation.swift new file mode 100644 index 000000000000..7610e2fd27c0 --- /dev/null +++ b/Modules/Sources/JetpackStats/Extensions/Calendar+Navigation.swift @@ -0,0 +1,111 @@ +import Foundation + +extension Calendar { + enum NavigationDirection { + case backward + case forward + + var systemImage: String { + switch self { + case .backward: "chevron.backward" + case .forward: "chevron.forward" + } + } + } + + /// Navigates to the next or previous period from the given date interval. + /// + /// This method navigates by the length of the period for the given component. + /// For example, if the interval represents 6 months and the component is `.month`, + /// it will navigate forward or backward by 6 months. + /// + /// - Parameters: + /// - interval: The date interval to navigate from + /// - direction: Whether to navigate forward (.forward) or backward (.backward) + /// - component: The calendar component to use for navigation + /// - Returns: A new date interval representing the navigated period + func navigate(_ interval: DateInterval, direction: NavigationDirection, component: Calendar.Component) -> DateInterval { + let multiplier = direction == .forward ? 1 : -1 + + // Calculate the offset based on the interval length and component + let offset = calculateOffset(for: interval, component: component) + + // Navigate by the calculated offset + guard let newStart = date(byAdding: component, value: offset * multiplier, to: interval.start), + let newEnd = date(byAdding: component, value: offset * multiplier, to: interval.end) else { + assertionFailure("Failed to navigate \(component) interval by \(offset)") + return interval + } + + return DateInterval(start: newStart, end: newEnd) + } + + /// Calculates the number of units of the given component in the interval + private func calculateOffset(for interval: DateInterval, component: Calendar.Component) -> Int { + let components = dateComponents([component], from: interval.start, to: interval.end) + return switch component { + case .day: components.day ?? 1 + case .weekOfYear: components.weekOfYear ?? 1 + case .month: components.month ?? 1 + case .quarter: components.quarter ?? 1 + case .year: components.year ?? 1 + default: 1 + } + } + + /// Determines if navigation is allowed in the specified direction + func canNavigate(_ interval: DateInterval, direction: NavigationDirection, minYear: Int = 2000, now: Date = .now) -> Bool { + switch direction { + case .backward: + let components = dateComponents([.year], from: interval.start) + return (components.year ?? 0) > minYear + case .forward: + let currentEndDate = startOfDay(for: interval.end) + let today = startOfDay(for: now) + return currentEndDate <= today + } + } + + /// Determines the appropriate navigation component for a given date interval. + /// + /// This method analyzes the interval to determine if it represents a standard calendar period + /// (day, week, month, or year) and returns the corresponding component for navigation. + /// + /// - Parameter interval: The date interval to analyze + /// - Returns: The calendar component that best represents the interval, or nil if it's a custom period + func determineNavigationComponent(for interval: DateInterval) -> Calendar.Component? { + let start = interval.start + let end = interval.end.addingTimeInterval(-1) + + // Check if it's a single day + if isDate(start, equalTo: end, toGranularity: .day) { + return .day + } + + // Check if it's a complete month + let startOfMonth = dateInterval(of: .month, for: start) + if let monthInterval = startOfMonth, + abs(monthInterval.start.timeIntervalSince(start)) < 1, + abs(monthInterval.end.timeIntervalSince(interval.end)) < 1 { + return .month + } + + // Check if it's a complete year + let startOfYear = dateInterval(of: .year, for: start) + if let yearInterval = startOfYear, + abs(yearInterval.start.timeIntervalSince(start)) < 1, + abs(yearInterval.end.timeIntervalSince(interval.end)) < 1 { + return .year + } + + // Check if it's a complete week + let startOfWeek = dateInterval(of: .weekOfYear, for: start) + if let weekInterval = startOfWeek, + abs(weekInterval.start.timeIntervalSince(start)) < 1, + abs(weekInterval.end.timeIntervalSince(interval.end)) < 1 { + return .weekOfYear + } + + return nil + } +} diff --git a/Modules/Sources/JetpackStats/Extensions/Calendar+Presets.swift b/Modules/Sources/JetpackStats/Extensions/Calendar+Presets.swift new file mode 100644 index 000000000000..490260796a1c --- /dev/null +++ b/Modules/Sources/JetpackStats/Extensions/Calendar+Presets.swift @@ -0,0 +1,131 @@ +import Foundation + +/// Represents predefined date range options for stats filtering. +/// Each preset defines a specific time period relative to the current date. +enum DateIntervalPreset: CaseIterable, Identifiable { + /// The current calendar day + case today + /// The current calendar week + case thisWeek + /// The current calendar month + case thisMonth + /// The current calendar quarter + case thisQuarter + /// The current calendar year + case thisYear + /// The previous 7 days, not including today + case last7Days + /// The previous 28 days, not including today + case last28Days + /// The previous 30 days, not including today + case last30Days + /// The previous 90 days, not including today + case last90Days + /// The last 6 months, including the current month + case last6Months + /// The last 12 months, including the current month + case last12Months + /// The last 5 complete years, including the current year + case last5Years + /// The last 10 complete years, including the current year + case last10Years + + var id: DateIntervalPreset { self } + + var localizedString: String { + switch self { + case .today: Strings.Calendar.today + case .thisWeek: Strings.Calendar.thisWeek + case .thisMonth: Strings.Calendar.thisMonth + case .thisQuarter: Strings.Calendar.thisQuarter + case .thisYear: Strings.Calendar.thisYear + case .last7Days: Strings.Calendar.last7Days + case .last28Days: Strings.Calendar.last28Days + case .last30Days: Strings.Calendar.last30Days + case .last90Days: Strings.Calendar.last90Days + case .last6Months: Strings.Calendar.last6Months + case .last12Months: Strings.Calendar.last12Months + case .last5Years: Strings.Calendar.last5Years + case .last10Years: Strings.Calendar.last10Years + } + } + + /// Returns a calendar component for navigation behavior + var component: Calendar.Component { + switch self { + case .today: + return .day + case .thisWeek: + return .weekOfYear + case .thisMonth, .last6Months, .last12Months: + return .month + case .thisQuarter: + return .quarter + case .thisYear, .last5Years, .last10Years: + return .year + case .last7Days, .last28Days, .last30Days, .last90Days: + return .day + } + } +} + +extension Calendar { + /// Creates a DateInterval for the given preset relative to the specified date. + /// - Parameters: + /// - preset: The date range preset to convert to a DateInterval + /// - now: The reference date for relative calculations (defaults to current date) + /// - Returns: A DateInterval representing the date range + /// + /// ## Examples + /// ```swift + /// let calendar = Calendar.current + /// let now = Date("2025-01-15T14:30:00Z") + /// + /// // Today: Returns interval for January 15 + /// let today = calendar.makeDateInterval(for: .today, now: now) + /// // Start: 2025-01-15 00:00:00 + /// // End: 2025-01-16 00:00:00 + /// + /// // Last 7 days: Returns previous 7 complete days, not including today + /// let last7 = calendar.makeDateInterval(for: .last7Days, now: now) + /// // Start: 2025-01-08 00:00:00 + /// // End: 2025-01-15 00:00:00 + /// ``` + func makeDateInterval(for preset: DateIntervalPreset, now: Date = .now) -> DateInterval { + switch preset { + case .today: makeDateInterval(of: .day, for: now) + case .thisWeek: makeDateInterval(of: .weekOfYear, for: now) + case .thisMonth: makeDateInterval(of: .month, for: now) + case .thisQuarter: makeDateInterval(of: .quarter, for: now) + case .thisYear: makeDateInterval(of: .year, for: now) + case .last7Days: makeDateInterval(offset: -7, component: .day, for: now) + case .last28Days: makeDateInterval(offset: -28, component: .day, for: now) + case .last30Days: makeDateInterval(offset: -30, component: .day, for: now) + case .last90Days: makeDateInterval(offset: -90, component: .day, for: now) + case .last6Months: makeDateInterval(offset: -6, component: .month, for: now) + case .last12Months: makeDateInterval(offset: -12, component: .month, for: now) + case .last5Years: makeDateInterval(offset: -5, component: .year, for: now) + case .last10Years: makeDateInterval(offset: -10, component: .year, for: now) + } + } + + private func makeDateInterval(of component: Component, for date: Date) -> DateInterval { + guard let interval = self.dateInterval(of: component, for: date) else { + assertionFailure("Failed to get \(component) interval for \(date)") + return DateInterval(start: date, duration: 0) + } + return interval + } + + private func makeDateInterval(offset: Int, component: Component, for date: Date) -> DateInterval { + var endDate = makeDateInterval(of: component, for: date).end + if component == .day { + endDate = self.date(byAdding: .day, value: -1, to: endDate) ?? endDate + } + guard let startDate = self.date(byAdding: component, value: offset, to: endDate), endDate >= startDate else { + assertionFailure("Failed to calculate start date for \(offset) \(component) from \(endDate)") + return DateInterval(start: date, end: date) + } + return DateInterval(start: startDate, end: endDate) + } +} diff --git a/Modules/Sources/JetpackStats/Extensions/UIColor+Extensions.swift b/Modules/Sources/JetpackStats/Extensions/UIColor+Extensions.swift new file mode 100644 index 000000000000..96bdd53ca36d --- /dev/null +++ b/Modules/Sources/JetpackStats/Extensions/UIColor+Extensions.swift @@ -0,0 +1,13 @@ +import UIKit + +extension UIColor { + convenience init(light: UIColor, dark: UIColor) { + self.init { traitCollection in + if traitCollection.userInterfaceStyle == .dark { + return dark + } else { + return light + } + } + } +} diff --git a/Modules/Sources/JetpackStats/Resources/Mocks/Avatars/author1.jpg b/Modules/Sources/JetpackStats/Resources/Mocks/Avatars/author1.jpg new file mode 100644 index 0000000000000000000000000000000000000000..9225f29af403eb6a0b5eadd1c37516aa890f5942 GIT binary patch literal 5516 zcmbW5c{Ei2|Htnbj5S-x){w1Ip^PP46rv=27-gC4yRjP#Qxvj>q$pzv$uc2Jc3DPQ zvqr=Wks>oD3}(zveShcsJ?Hn=_xzssec$(W?(6k_zt7{opXc0rF5@#}0pK+;G&Tf4 zAON^}cmNCnaNPjq=>`C%rod?c0N4OfyeGhX7#)@XqK78{fb&5B`0xt)b1$Fi-&vo0 z@PFbzg^YDT{iX*p7#ZY&^q0JFP6<%gH#TMdGuxs5#1Do zG@}FHV*`8uA28?yz{Cdv^MM%M0Q7K87SLb)9d_7(n83^|tPnPK4$i}ZI$nSY1O_uP zgIQSqECY%<%md7PEc{Z+daMHGj*t@pN6+6&FJP0_ukIAI_(YPq;1n3c&LJdxOhi;x zPX6Sn(<(4kHFb@Pm#-KY8eKIuvHZu%8g6szwzG??o4bdnS5ReU?*L^s>05^i|pGn%cK@^$m^hn%;MH_w?fXK71S<8=sh*`aC^DATBO_ zTVDCT`h&bl`MtHhL*3o`!*%#P|Alqf|BLMZaPb{-F)=fPnIV6;KulqO!1k84XzM~rK zpYhsU)e(Ig+Td$4oYZP)(C3!2hd+YSR~pdq0|{1g2)tew&=@zrjU8nGJq%z5vVdIM zFRdydXZMI=;=Av@4X_(1zi@BP2@)C2i8%2cOM)wYhTNKLe}hk6st^2fr9mi2#%NY` zG~ky3hVQs4b#QSI({1r1Pj*tSx$V)nZ|3G&UXb+gD+=>x^-mBBQeMk<*Dfcf zJw%ZHVI_sqZMzU0G`+I#yfLm5B^k#G(&k2`HG7MsQ|^};>MMX z%bVqP;bd$VkT%qoLbWAe1-#!}gT&MR`4FHqvnGj}xOc$cp)R>IEcpD57gRvC6m~Y9 zeiNm5z)?+Fx%p(woLv0Ydd7ZwX6*LvpbU5VPS%LREAel~o`(QrMCoxpGCa0T2IVx> z+r~70XCk@VfB^`jt*ON%ceBh61|S+N> z35K<}hDdzc-!EKMlzH$OF29(>;+ZU8a10kXQql61Gd;$;kLG4PQJ;GMk@u;tC+yGk z;pw}+WO)M=+A8ps@{o7`3)On=C|$Dc4Jaw=dV~u+-rh5yrMoR?EOBR5H&AUYO$diq z2-9VZ&=Eqf%udvw^<{RCOnq^BCF9xL*ET=;n**Iy22iuJHbERtnR+dgX;g!(=|)k` zmFU==j!I6xu%1I@>;2WY*E`&eq$;%21R-!z-9g#@$>`yon3<(3+Xqq;&XP?PNpW=# zn@&j*jrP0pJAcxOF&-bB!?b&!aAjW2)f)Q2W8WUnR7}oJg0&y@YG^uW_n#8ZdC@;R zdv!kImaFWuhrd4zI;}<|KT@(va4rbw)w#Qcd)YxM;sB)Jp%iYa=a`&ZJpm_^?;BBeUb2nlq7u7l%b*8Fz2QBfKB(mW?i1~5tU-oSy; zN2|xQl+Xs-pyFL;Wn+oCff~)d(|9p~tszLnUi>zU9up#A)~%}cRmq0X;Knkr)PHjC zoRaRvgEwuE=zb_qu&sjWX^L5*O_9OXnJe6#8`P;mXG-*~Kuh>87XD<(j4D0|pjXhmZLw$vKF~nWkP;H8hCM!E`FMiPJ1dhUtVC z7y_Erv#KuP>@Sz|ESqcA1{CI`wA|pxd&e8K%lE#Lp=-vZ%e@Xv%_K|`TH8I+Fsgq{R^@=DuGQ}3>wWuDZPg*6lSP?I{NNjIIi9n#!HF2GLdoGB zo-zRZx27AdLsK^mQQ`}f9uy+M%7v`rg=w`H+)C6EH5tSTqwU@@fW$(5BTesK*D+$) zY=`Kc^CB!pG0|qP5@pqsC-WQUm(#+xI8L2re4;<$JzhuMVE`)v6g@@fxF-${$+ zJ+?)LQ1B3PPjr8aOQ1Ied%*E!p^WCr0GL;)SfmeoM!#9S`OPAkB+Z0kBrU`gZGZN( z!EVuNamB5Ju$>)4+e|fAu}iemukC)H2^chu&UIHMyQUHWaAqF%A#QvhN|jR@YZDnH z@+9P)#OhBjT+MOvxjtk6vi6k+q45P`fXkR;ApMQ?4>l28MBBL(7O2O1RI1%^`-eU6 z_SxpM;jaVNc`S_1# zX+}(Eny0}=6vNQxE*gONHf~DykhW`>&I4F=B#|L%_+E+F{5ZUQM4~enpiF_^GIx*f9u%ly#h^S0BkBy5pRW%%J2`& zW#9VVG-R(BJ%2iPckb;9GpmR=oRYOKaWDmK(4>Wr1&5Lcf0Yv?^q26WKV~q~17vrO zK4bN{}W5GQ&H7QPUN7)jJKvsqT_%pmeCnhOP zxJL{V(e{db>ICcYiOmX^4~wX5obF_EwOH{ox<|N33%ZyUgD1iqXGM4SZ2gBR{svL* z`A{J{|zJvC$)f_52g_fh1s6;Ux0G!V)yKd-o#qCc%HruOy|D3-n? z-H6N4UC3epb+S8gXic?F9KY=~OyTNIc{nsHU4>&vVnUp6vzF_z*72g8Tdnf8+#H|2 z>Q3z*X8>Hz&PIHe+~zYdp#yK^U|uyDzQD4BSDdDpQY(w2H;J1Hxwfe11VY|my_+8G ztlUUvA#xH%wek!} zu`Id8CJpQimS7e~10BiL5<9z2Jbg!=@61^dc^5C`@eE`9?d{>|4ZD$+TP3DMx4Hyx zr1;Ibxd&puq!WBoo;76%b42+o5O6cn?=j*BGHClG;?CVV!HKh#XKzEsa8B-Z&KaE} z1uVux$ATWQvS~+UZcEkBUoJL}qxHyUCZB>Ul_%DvVfVFaS@50HGq}z0M89skWfb zo+vp9MYlAjW-lMc7`?_q4Q^HV>ZDnl!hOoNSwn-#da!Xo7WDS6VVc*OUCQ`4FOhNR zGGk{a5#^tc^!DI;S9-7ULM)TvlC!joXu@BsaXuo{fJ_zqeoP@hq^o zEZNZJEpG2;az37Nyvp1NDgUlGdW&E{LEj&V6ES zj`c|F#vKN5dtYtafnG#AMcCk<^^7QxvsGpC*sXLRYpBU4!9>V!V84Gs z?S611996me4c4fpjPoubz=NR&Q zAXiE=_UXOk?Zq^cO$7V*ue{`FEWMQQbH{?}?~Ht$x{!MJKyc0fm|-sed!>5-_`9;h zvh^5puJ-EgFS%DQ)O$76HoqLOFY730hdtG1Rail=f)%?1QvJQZ(rhI?mV$oL6*}6) zKdhh)9arrIvo}tpA9@VJDlpnOu)F5BtDh3Ei6Bp{7|nqi z5CmH;2<~0}@qXDFJ1C#wyA)^>c?A!hlYW33+z_A%w35CRzc=%9%F$?Zb(PAB4P?`b z(N%|vj|<)%^BS zRrum$TMgB-09N$^Bz+8g4{^y0!BN>_Q23Uh<0`T^*}`_icn*5aS~;UuS5O5RgW-i~ zoBEo5YI?VmqI(+2KV&9|;^!S?vdZVeaktmQrxohFExTwIqH{4L^oG3#Ba;sZ~L z&NXcu2Zzck4T;F62`n&x7-)@ZJE=1F8UwihYj3YFeZShT`W?8QxRF++@M|(o?5wtL z0pY~W@;nsNySY=?b=P=3y0;~~nbXhQH1gcN8mfh}X;4Cfi!hy=u0;_H89C z3Iv%#b5RfqPJ{|o&nC)tVtc~%J+oNf z<*Owxj5g#$C0XRDaBS`?aFwy(-U>l6KEgc~_B}Y|h)R-v9`jW`qYp`El~1k zTew^;KBTi^Y>NuX)&R*e%Q|z-1MVZIx*`^(_uY8747QE!qVgkII8Qwcd7EC8oS7jQ z!L@JMbu$H4vYaKiw51ljeq{dr#k6h9s^I3@r=1Mo$@$}5BhVWYl$~{veG%$~Mbnn1 zl=?wUTS?z0t6e#~o{~h=b97gmk>J)#rILaIwqFMUG1}D16sfOKObQQgtXL~A?U;|L zkZo?;B>KJZn8Vzz>V0ZwZwE85POg-tqlY`z&9*V*>@H6HC-gO^5o2CiF_BaQXm_f* zBdwX(Y!?4zqw?Gt7t5?;C)$ITM}A}YY;@A9X7FJ?i*g!p|6%FWh5b}-f^H%L(jrXy z(vz!o_GmgjiZA`syuW4o4r)l}<^WfpUf|# z?-A3)sr?JcuBgkzL-Q2a=Hj@X;$~PyD3yeC)Au}~@hHpB#C9jQPc$BKZs(*ZJNJ8Oa8!a6qB0+sUn@Bww8{M4MD|mUE_OFK7nO96Rhq zzfW3;`;CQABf8#4a7CM@X8Q`hXmZzdx~DHWj0O~@2qChfk1|_=F;NVlZ&STCGw1Q> zP8~uPNzvgWuXJ)FJN)+Yp4{HEZ!~LXJe;S7v+xykCS;LL}?}T zA{B2QPMZF^v0&HD{E%Crpke&-+{cXa#Go+4TEBr^9{f&zr_>;7r$e(ilL+fP zUf6_7fM-_R0IhisV%+7CR2yNJy-GB#J?Ur!X*4@Y>-V

*117PNJOBUy literal 0 HcmV?d00001 diff --git a/Modules/Sources/JetpackStats/Resources/Mocks/Avatars/author2.jpg b/Modules/Sources/JetpackStats/Resources/Mocks/Avatars/author2.jpg new file mode 100644 index 0000000000000000000000000000000000000000..1b793bf95db5586935f5432d2bb04e25778eb01c GIT binary patch literal 5541 zcmbW3c{o(>`^OI^YsFX+VzNaf+1E_;wU%s2A7ZkTWs;pSDv4zGLD^+bOqL<*Bs&$^ zChORj%wWVcnB}Lwzw7h8uHRqZ>-Rp-dCq;E`+n}{dYyBhbKUgs^m%~G#K719U|;|M zqvHdhF90|7LpmlhM z0Wd4z4R|v#hyjdX1|~2Ay$gUG?|FjZkNzw>o){RJKqr`4SlQS)jswtK03!nv6C;S} z#EIYAFhm^t0TB2Ex2S?HGmnKMiXBSsDcMs18{sDnO!6BhxQO}~E$Hcyf zOHKPbJtOng>#R3L#U-U>*~=BjUSqtKXrC>_w@Gl4~&eCjZa{|Pfjf?E-kOD zuB~tUAnyL$+b8`Z9~}PXI)0x2#yXz=P4<7dz{gySAP^IX6yLf<2|?PfB2mm&#isBGvJ@%@GxB^XZQgY0767rOydxJ8c|IPV zsSr0-o5D#DqFC}P&fD}x3PgJ2xGC54ssj3b8X2e5T(9Zma3hOUh`H!rUC`L9*b(ik zr@EKxz|UgOKrIcKM9M_=8$_atv(pqSjs|5RKI9lu&4}`3)-Y_v4a?;>+$j(O_AKjyzAul*JyGNtJr%U36v`Ti}hK@u( zz{#h*a7cFYn~4EG&j39VML$yEa8N%CIe|eS1~xl}%CE+v&=^>)$C~ zkkjO#{>Ym71?2iBECC9Lomx7ZbfkyzR`zJvcz7}rKnd0%KJTv$(}xeTjT$~Z4xR1} zw3-Ihx^G9_)r8KhHXlXHd2C=;P!bJDc|^oxV4qqUJ{~L)8}Oc&})VQ1}$?ncD{t&me(xJ?D*%B7M))!kD&R^t}&$j1=BzZzyBs= zGMskmfEfIOBIHWsAw9*UPV64?Wgn>^_e7uT_07(lZhKNKMXIO91;Qtj^zu7#ocOF! zqhyGtIzQ(H=!@cWjYq{xBbatM_m5fD?NKXl0B{JUayN>UNOpY_5@Xs{gS~$vR^lXk z^D-DFpp6s0RawMJEr{NtcPjaHrQleyEi#rHd0$O)79!a@)5Np*#JX-R?3wr3WsC z`zzib3;Dh8QhY4?-#C{U*dA$Fm*6gd+a6##X<8$2N$~W|vxiI$v&9c;1`5`q z94vewJanMrx;I`K{`i+>j?9_Ae8C(HV?O>=*&i6PN`I@`4i(|E)E0cRJ!7pWD_+yI zq3oPKzKRai4G-MU(ETOx_m?LB4&XF;SB=6uIY|fH_5ysFi@3B&E`BhJm$GFi+K)Cu28{@Y?UJIOdEf_M7OxWc!I~Lp0i2>rUs*Tbwckz2QX`D1nKIz zT&b7QSVL+=@4hQ>lhpKbA+d)4sd5tYrq0E-Ci_8=Y%a&7ASnR5%0f^(GA3^uj&_q; zc5n_O-cl>GZ+NsdXoqF$B9o{ZI^Yr7u1wMWK+KPUy2=@xQtE2Zn-DTJv7UMFrvG#1 zRO<4xlf2&ADKLN>vk*g|Xq!0}lUuCOM3wqvcdO<+{>ELYAw~`D@{GHr{m&+oYga3m zWbUk$&fxbO?J%ZtFwpMQF_ zJMuCH4Z&}qC?|&! zNgnqVSUPK?0ZCFJ<$r=sK3KV&^ioHv*wfX$Cbj2|Ea`>7)AD4LESCiQs@eP1>c|dV z9S%FhS@ODag6hP?*HNnxe4w;bjcLY!WRe%_V~P8U`z)ZJF#nT1@tahK(_3F~r5U3{ z9rjWihdiN8r8_nzvw`iHC%dpt2vYd- zbF+WBFm&`fDaw)?L?0iRsn26cNk^*W;*i1lm}jj9Us~rp&pc8RDQSH#w9i}fjqKuQ zm1ZaGkiIkBd8l-d@$hZR4EuBnqw3dJ)jfd>!R*VIM|n~lLW_c-^bwjJk_S25{7mo@ znnLcMUyD0x9wg*yBVdWW3j5GaY!U?12;@GtmK1&<^YcLV37q z3*{J!y4QC_U<>XmF@IQFe*5r(?12~Abv~ODOjiBbF*O3O8zbaRIpVzIZl`Q1rEJ3< z9e)}kG*xn?KGAxJ!o5a%_H#>_%IolB4&w1ws@*0e>o0=y`3kB0`p)__s#73fla_5z z{63Xr*FAe^i#mZm z>j{E};hUdBYj5rH-A035T<-G+Ujjr*F-np1B{(jG0FiU)<8bSVN0~tmJT*~UkQanyAH4A4ozNyz?UR4B2;a+ISRRlFF$%A)NV0H!XK$Ij2$k z@#)h_u-AeJi)l|A$xxS&e4PX65)7WaGS1sxXH5v?ZT;m<))D)1aX_BU_yK+ym(hRD zDV`3E)ya6q=h?9f9>t(^|!TF2tmkOvIpjQ(94TPVLnojCv}-{BhQ+)A9;*GOI{4@yN|Q@)fF}r*ahYdYAVX)M0YjwKIV4^NeD@XsA+}{lyf- z8|vfgJE1JE9V{y7qcoXnq=>z-LMl$n7**2F5@H`P{XKOvEm`3nwfL=1t=BLOG7B18 zobv0V1+I32H4`dC61@3j{4C?fsBUC4FHgQ=xyPC|g%oq_JDR{f4_JNv^CcsS)y@`4 z*y?7}xhqo(t}kMbz6}Z~D(11O%sX&SU~dgiECs2?5x1w9785?x0T%SSD}qfNTy17) zMh9$b{qGT@?AD!4{>};%qY0Z4AiW}BYB5rt3^N%K+*X|sT1<8$^fbkx8pLU3g#0Ors`6()6 zcZLmRwiC|@u(!OexPST~n>r}*1VUAd?S*NXfO4acmV7>3X%DaJY|@;hTquMua+%)S zUJj!J1kt7NtG>!3?~Eqd2|yS-1xs{)h6;>z82NJArB7;D1Wlrq%zwGFwQhFqD(01X*m=^noc-x?g%y{Sdn*hSO_&s1#WlT9Sa7&5Wa(Nxm(67TpX< zT^q|$sB0f`j&GMGVI4=cshT!;Lkc&fhodY6(~aaz>G35vHYJ$(3;Do@<0!$+FH-$E z_L<;|>a;+T8lGqA`Hl^Nt?V=G0Zvz?X6R9;_&(ok9F6z&@`z2A{m_q(4hB3K8{6H^ z_Z|c-S~3icW*s7Vu`)&c!Hcbwn!F{jIe}4u!sS`+QRGJU;GmW?Rh0PGfylL-NNLJ25T z|4@x2+KCtftYBSWmJeOehA*#L)u0d$?tBQy{TEJ;cg3A=jDjIA1-uV=OZujRS0#SZ z*ecYe1y0a9=j6xQg^{Ck%*3ymvifp~O=TC)F9rDDdLfoivQ>43J9+gZ73Q*WXJIGA z6+cH>n(fNkV%X}bkIeWy-iJKk&!YKQ_SwBTS!eQ7tVf*=Xk*JP*@pcxCaA1s7b-6r z2G9X3s<_Jb(=b;$5SwbM!{xM+BrMxOQwY%$x)FNm6T{-JtGo6&7wA@3W{HMBnP{T%8}`fjtp+;smN zdFWP>#c9^vVo*^8_@Nz99aX=O`AWkf2O2!y#y>bTx{5&Yj1S(EyA#>Eo`G6h+1!PG zogz?n8>2EnaHb<16=7qs(|Fs5bMZn_x$FVbepxEGzzv{1$GnL=?#3zHF#TbKJfs`; zTqxk`4LpGq!KltHnKh>royM+vv!>@^*wgS7%J__Kg-2NS6z*RnY>*{iss?Xm&yO(h z7?;;}F)F0)=l4OoqQjs}lJf+@$|duqhJyO+;|S09$o^o}Z?U)c_ED7>wAD>oal2_khBagx-EjnbF`JXcgQI!d{I!!_m^3srsWv_hFS`q& zv0HfU+4&ar zl`<>|a#pz@NZmeE^Vm`^IpYRPH)HuBq4p`2Al<=YmSKuWR|k{5b@4{ zVTu>flG&$v{g4_CXgQ~>;M!)F=HR#(1aDwv_=J5O-S=J@rx=aP!#u}5!M*relP>PA zB`*gR9y$eQ@(O!b;ysiu;`d>q;X&M zw=k?X_;40oU!Uv~GNLe6OKHMu+Wh3811I>YFGcfKfA!8WHA-35$Igga_ma|Mhs>cX zt=GUmf>Nkcv@D&=A)Q@$S6U|WXd4ALWGm(qwH|^^dO{@T8_W<_M8*&FiAr5rRV(wO z+Y*YMAWXB3WCL7Wygl4jN7qnus}Sm{&NTJO+g-jP|um`_!HXIKpBkwnYn zE6vxplMC>sRSASADFj$RTXT#{8mK}x`H}jb7=|BnUy=?y6C#z6E9P-0m25W#A0~?G j=_M(xipL`gYChs+Ng+H>5@iRfN3v^w9(^sTp-=t~YJ$!w literal 0 HcmV?d00001 diff --git a/Modules/Sources/JetpackStats/Resources/Mocks/Avatars/author3.jpg b/Modules/Sources/JetpackStats/Resources/Mocks/Avatars/author3.jpg new file mode 100644 index 0000000000000000000000000000000000000000..3fd382ed7bbd0e3693c124ece7ac99cf877977a3 GIT binary patch literal 6137 zcmbW5c{o(>|NoD5kR`i_k>#x{$yWA>EFoJ$*`^}fglyS{NkaCJL^3Vb7?X7nCOaWJ z8S79X+l+NEW`5KA`@25h>-zrny}r+LpZmP7b8qKy?)$!8uXCtBs0+YpQzH{2fQ}9T zuG0>HiU)2OhI+XJfVnv!3jhEMKo{=?Fw!hq6+oDF0szB1I)H(8rTcsD9sPewAHHMw zZ~JdW>IR^0<>?pX7wGBtNL=OOB|u%@#GLVOZ#4aDOaE)09urNTN&~7GSD&!HUMJ~C zQNIG*EWktHAp@NRK+jFbz)eT(0Kl{{ndtu2zs+bF9X$gh6Elc~mF*O*LhWgQo{oWm zo{@ox>F+Rfk+gDvk(-I8hbozT1N&U)J{+q)DDV6&HPg&Un1clBB zOaCKt{(`Kk+T|8Gmdn%X){eM4hgdq-zicTexPkRKu=GbDh8SunHm3z8N~drK>uGX|BCgmPW?wzS|@aWTQV>((b6dvW|sey z{jUw`ENxkWs5pR~fsVGA7`OolaHxpi%vMb1yhh5Mru4Low_LbZ zJY{spN8$8m29g!Mm$Sn6aj|se+6(yV$?SGmj#R4nsOjyWmCLE2CE6Z3{VuZcCMNw> ztvn4scXE#{_svdHH?dyxqNF$WSCkcs@~oWrt{%Lz)xOi*oJ_ z=A58Ak|5xvPmKDJ@KR}7A!o$NTZ+efGg*|)6mH_}2HJP|Ta@I)rmBC@{A~?xXT^c1 zh(RGVs)ZHNYJ~7YPLb(>Vrz@$9Y8(W;$NaDwM}>m3+U$)&FrU z5c)1(`g7(w`l){mbom@1X%XBpI5pYb=#kPS8t|~`8XlH4Rx>rJ<#u~;OH#7&v$S(W z88>w0+OY;1PC#!s9$z8(EiK1+WGY5jgVFDiI@<8AreooPwVNGj0PGI~;}Ko7UiqZ_wCs6Zw4e1|W%H2rSo zt4Qw^bfpgUk%9|>TdvKK#03s{DcPT{49)O#!kR^Y2}pZ#c8;xbn$a61!~bX8BbE`N zfh5>KlN=6`wArXY&1B-3Vw^%n@o?0cmC=7VHkM}_CN?E~+5I;p#)a;^aZSg>Q65X? zhr(gvIha1zLSJ?ZlNS{xy2Ub5N0VEEWC2JQn1G2=f;HB5RH$;rjo?Vo8!-iee|C%} zv(wV9OWrc-CMrKhw<<6>T9MLATFlNxzf<49+=YDq?E7uGOkdDMB0G+(av6o+_y_ku z*U167RXuu;vC&JLpm`)?3+tMgR149ixY$9b>%C%J%50uzY~hYf$aWjupse6d8G^Zm zdx}(yFL*_~iuHCj_)pJq7gTJ<-II39;H^5P@^Fox(O+ zD9qv*>XEhiV0x%$S-yKL(`jqt9B~;Be%d6Bc5yId`_uSA(s`!j@VC;%G0+*Q=&$Q? z35!l+o3+_1L6O#@rKcc|A#zTXU@EXsgyafpR#~C~aqtLy(#@bcgVaSs=!+W}mjL9= za;3GzLrlkkoT~hAG8&AE6eODM_mebG*JR?OBvwOGgdP^;TM$@Q^m@>*gSo`Wn#7s_ zY;=X$hg8>eQ+4n)t697EgUlIgL%Uu^$vVZ-w`OW<_jG-T%tHYxWnB&_@1#?7*HA%5iz z17CwQWKdB&p?=OIaMmRst&Z*MG2e3T#Ju23-qjsHv8A+Af$IE}MfB4sMj~^TYMlq( zy&)`cfeLU>eKHY1;Zt0LHtu&rlx6!Ts!I<))IYg&a0HhcEBUyf@?706BQjXm`lR~q z6!E@m@&n5qbcO(-hU4?nL~{xGJb|`zhWEDO>RtyO zQ31{4vevpEhT=i|#NzhrmamIN*ES(6`gTk`yJ*ug?d#yaL9(D@tLBv_wOIC0Ui@rQ zXK}Y5iuA=HJ1nbZx_m}T_hCiH3+ES7ck`I&>|@cr^CX$#^^FR$KQTYI4yQALEnSU5qGoeJN3<{OSC)28`R2B5d&>NkQo}&UhAGwFoIvE|y|Qcbwy%@D&I=tMLak(rmvUK@wep zu!q+Dp#t6cDp+FdL@6HcAMwd_L*CWyO9FVkjLhBX$5!VGk`Z4x8@*#7b}6RK<$P}Y zEfUvxn9iLNzM$th`stoMj<-u!+pOq!4h>M<{%?BlUc{$9^l!`sqMgfV$Y; z4kVduj9C2nT2#<_EJMHh7-CYFVf0Lqt~l?bpr4^$%paymQG%pJBheiV>VR-sRCc?Y zYV!7iO31KLDgZ-IG2Ch(J;I8>6@vZmVxV7Mo$s;|posriIt;fh7V6G7U5zr<&MUOG zjxoXZQ*0nGh+yA4QcBJ3UAZrzZyCQGyeQ`ppSe}`F86*ai-z5~%3cPwA7tPpt3?TJ zPV$Q+B3h4_hDSE;rx!MJbF<#25IKex_Bz8U*kffc7a5{i?~;#)b<^pV8_dDCd&DY? zt{w$KhnLq~{Rwr03;f9F8rDU}3->ZEtoz(%d|9=w>yVjkYegriA(>$T8xD&FyOj

M~UN$RSMmmTV#V5oq*85$t_wM@e4c$%|j zzaL(sM{0Zge58*1vGn5vc5HhR9}HoFK|W8mmr()0>%|H#Y-r*6lwQi`8GZJ^RIMD_ zgrmt3ywd41BjvckBSZorY)+t%ZKT*H=zwg$5OtL@MGQmmIX_t`h^i%~Wef8#hiRbe zXb~6-c5$U1GUK-l70Bvc%{_S6JO*EzMN&tOZ15e1PJpKHabTOtYkPj8UpsOyG13H$ z(UxjRkQ@FK`r-m*2+OLLmWr${PSIr#4c`buG2hLoXs|pK_8Pgp8YLYzlx0G|to!41J0i+nWCH+vfgs z)4uDr|4_7k-wA) z6$Q#G^%QWFS5~<_UUmANxd@Z4f!&-}<|&pbl3SEE%9HNn|Ky{x_boKHR=oI1K?ZD3 z%Q7A!)Z|R)H}m4})AOCt00GfZEBH++@U%R%{U%8~ir-)b7a-128_;i0-bW7%ig{09 zj`#=i6cSj(g^3EDV3NIHZq#eK`rJzy;Z=Z%(;i)qt)Hz_$Ok^Ye6;cVV1MoO#zY_Q zH=D_5wm)EQD&U1Z(?8+m&lPWgZ1LRI-iE|Wk|JTd?Mr?enZ!}NVmpT-o=gQef^_U} zjNUM3uJmPUDFnX+Y^6B90?Z(}Rp$dTuYDT=!NCPDC#M!&ytOJ(D_3V&&} znV$E=8(Sy$Xwrx3wDPYZYp4L3*OkJCDe)GrX`}*e16Qo7pUfU+dWBpvd1yDGBozzq zvY(D?b9?Y6dceyeS=!pLGeAKB(Y1=Q@K$fo&&;mzE~x!tO36cMNp2_YKRNlLx|MnX zM&#|tmFQ$MuC`7czG{8J{Q2AKa~5Y(L)Rt~DTy>c7#}4?geD;Fo!JAQE<0tp@h08P z@jX<5{Y@n1C`4us`^uUixXAT9N^%uag?z4k9w(DTj#NX%Wy1I#uq&&zl??>3O38bk zePQ&7b&3CEpR7UBTJW3NS!hWixZ;~X*A6%7c-DPBFXSQ4vyCn5`S}HG1GR~6*H7Vl zhxsyhj6`}g#NIIX*bv244Ld%d0!+wfkh*jZqN#rPLz?rG(=m_CKK8moc4qDlrfs-0 zlS88<>mAF-2r73zgwmWFLPq`&_)%@2^?urqgYYJ|df^14N2CiKhg(B<(8NzYxBHfHOhuTpEbwXm3T zBVG4Rra%i*rQeyuqI_Q83&zANrbsHsj@OF*%-3N%3~TNZYqyPl1|D}}5}@06^$yTo zmT8*qm4wXBojK*|)ocsLN2?-WsP}PuJLTL&uqDqNsknpp%vSBG9N&xXzIT4*BvnVr zTk;qP3z;3Sr^D|0A(2(r~2dE;<73T2V}K0a1%jW2s9ZQy`-WuLes z{|gjy7gjN}QQ@k$eDT&E{|;?x zcdz{dBwq(sRj3$}n_sf@5jy|t(}v`}@xCu<$74Z}O+IA*@)4?BZs&(HSNhlMtN@gA>>Kq`x7%nt2l*q6)QrKfk83FX~E`}oeo8B6(%wZyu&}64@$SmKmBO6Y8^PbP8~Mu=ff&wdS~Ql%dIpU~Je=Sl znm=Dpp?QA;KlSQ-4YUjAurWui2X*YgGS2>L=Sn3=1u`t*;-}J=>47OVjrA+wXL8&c2uOts@;AS$_u9 z0^4!u!($TRH;br1C$9N-L!ejP%*?!#NuY{R(^D5a_x9kmvE0>`v5P3&Nbsx(JNM}J z(ajay{>OLDueZ-6ZuA~kz1!QB+&!{?^ZM>=_~W=WKb5&&e`1?m(`4griH!BQ@plbFhj@91(4`?19S6nr?7T-;S8>`D9SGWtCOq33$ZOY%RmA9BDv-&81UNHk$1gbl!DXde^Yf5PK1nEr1@Q?_TfwI)%o zhBHe0^W&4=OeB5$Ha@k2(Hxk|`e1%BtHfNyQHq<%eXi=p((mtKqFmRYjZnf3DnOEv zS(iDshiO`Nr18U{jYPYTKvQ~;C6EyB)kwQ4ZouQ!6ZyE>yv&=)y%?Sy(r_S?vwY>p{-U3Nj7BN=h)IKeJT{% z$-NRjwiZ(e3$3o7L&Tm*NVN1xHQ9YX8lt_bhZ=l>c=)CZk;o&y`7Ek-Tc@cqR(vT2 zYDW1XsMm7g`k#P8R>p|@+HDDE9!JrXaHXlUE7WmP>TgOH^LYzq3#Cr`7@OLx4y&2K zl^>9AO=~u<>|Pno7`q@Hekxq_aNNapz{mfb_Y6xl9vO0;F?e_PbOg#fvT@E6o)mj< z0GgDq=N^;fK5E4LtXngwKnA;zwEs5B-Z8&YtH>k z^KGx*VvK*yZ-5DdT`t1ol5g54t_be$uxm{lxv@p=|4ck=OLAr>j>d zR3LF#e)jwJB~U4Pf(kr8m=sq2+GFtBiw&vD5S>iKc%emxDl(mj+54(PhCI)6I-cji z(+&=}MyliJRY#KDs6hW}X`X3WKpy{zb2f5!a1g;k={x$>l8-w`@a5|_;*&Y5MB?=y z-KGLzl}~iSCsMk6BrY=QZq7!^424iV8EJBfu$6VsMtVmBRaiTv38i*JH*gSeq# zql0{@aaXTNV6p&)S1)4MQsbJ_EOS}!D(lIbCI?g`pSd0WR9$>u?a=4bD);IYR#?%X zDyuf9GQ85sa*kPd*j z3OrH`ba4a#9UVXz003HmEXD<(BuOL_fP-`b07W4gKtZ~a{k>O6{%@^YA;o{>zlOve zKvv(`)7R6-+0%nx`PUl>|Hy*>h)grw2@A z1KcRct^wrCWE9L~#6f_U)F&0$KlrB^sUjn%proRvp}jzNkz~-q1dx+aP>@qnP*MHu zhAfPv2Pm1TSokHBsV_garMc#P<<860B3c2JhCWt+4G1CbZl4IK^%hDg>Kvw zzAGgyBP)0B!9!Iwbq!4|LnC7oQ!{f5J9`I5CubK|A74NJfWV;O$X8L(F|n`X($X_B zv$Eg3&3Rv3Qd(ACQCZd4)ZEhA*51+CKQK5nJo53==+yMg?6Z({%9^&NOXnpl59 zPAU}S{T;vr_TReyS|Bcw zrX>w=5x7J_Mw(0%%m4&93CDTlpKqN!+-Lj54Ly=Z4HAKVS+l%CA;UxX=kSG|8-+8* zn%wG})`bX;bG}eTjC#M0fga};^P;L$#&&(Xx%G|b0-#GzxFxLa9ywL&_$pn(k6|qs z8Mj}rR`><_ruY3Xa23JN30~Ok{*mp@zn`I{O8Y{^>o%WuNRlbUuP_3rZ)zWew&>9w8k&!n{-cU2(61qFQ$i&nAB` zWd3o%s^bLlk;pEzbXC@ILw}K$%FByY8x`z8J{}(Brx!z^EZJdw$0#-2%eY>i>+mPt zcIoR+O{6Ew01855HBoNai!71<+Iat+};)jsuYZ%K7XQtsdmqU2}}#u_{(em!}B%r9lFt6^^Ei-Vs&4AsW#t%k?7 zek;)5=mAJ+&-preUj%U>~{pY}GWzi^87FO0NuGCQh@k6c}OixwWDBonh9q zVm?Nnsh%5W6`lo#c&U<5Gi-jb;Ccf}Cdz%sotkZV`Of%VGl&e}t`PQnL$Uj_h_d>aVhxL5|%#$5CCAkT;VtNHY$?9&>gH z{K|KT#sKzSLh_+wE+$4a)503#;Sfk`O#ir#d40C&WcFv9 z3qXKS0}iKP3Qsr9a4a%>7<94=(tTH{V_6V=uPm|jLCwJTS+|g^)8YFJalIa^vd3p2MZFNz`c6F4*b*U z#Mnc(;=CmsnMvCF)?cCu$X#LNIr!M->dnNclBug%i~aX&Ikap_?gg8lP$NDTSF3Lo^*E)90?4kYJ zA_OH0IF z-j$^|{_$?rz-UByJzwIse7u>BHTSPzXqyW#{5% z*v{M-!tQtlw~jAD;bAN!x6fRsoHwggYTER3Z>w{m+1C-&hf5+jX}lhabG}F3)gmUg zoK`*9(NNt$6Au^_@v}I9l-Bn zWeAtskSj?WMBvr%salh}*1mDqLAkm8;2U;f86*{8d!NiZ^$QeTAJ`>#Q|J5Wr?ZXk zC=4`u~UfQ5?~#cmlUXG|Yy#c<@|LnpyI6!?5Z-s@Qy) z>uqJr1*b3G#HBcf_=_DRua4-3Fcc()2rn^Gt6r?AJ3L*VHwPOgoPVg^34euXmWe;- z!ry~1EV}gLBn2KdI?1jiBu_x{LIbMw>_RA?eQwg>pb$t+$VcM-@F->G|0$jAYSu81 z$R)q9b69%grl|UbcKKrB6ruU7F4V9n)Da(FMUcR>tS#3%Rhor$_=POk#X&o6EzH34 zZjf^Z-U$HT>{KQwPN3llgvUXZ+U`i`ySFX$7uDmaq9yjlj_3r%B-?iZDn&1ep|F)x z9ekVNO!uRl^@6gUX^t%&)g4o^ol=qJHeR1Vw{C~mKO|qKTMPVvURC15)WEJ{(w~@V zhF9EbyVLC8c_IGIv5dR`H+hn_ADlY(NGZ$rcTFf3C6_*A#n;x_lE&`av5RWDz(mKd z?iJOr*__3CUev||#!F$;kzcYgTmAmc&6CZ6?lq`3Af3DOma}5-<3+?61f|$gT8n9n zSgI}6=>MX>dhw=w>PUQi!ABTD#iX>(|0!f>a0I2V1oAf9f$J`sFY z*K=LD#`UtM<98!_Km@CfPO4N~&)xG(8##baBa(W!x*{fY+h>g5cg#FXdf-6>V3Ihk*6js>{ScH=Ds`)|l*4jtU#!xPxi{zE zthn2z6og8lG69X|%c8HmopG4Kt4wN-cS}3`hi7B1o!TwPXhu)@}mpP1u;O)1~tM<5!m%!g`pdc}Iix zc2z=g#TV^>iE`I%f?+^7!%Md8H$PlN*HRN?mp%&3l`$4onOwC9OKSCWt4-`Sco3EN z#s94-NRHrbZP2PDt-XBPw;~6}Hdz}d(Bl574-qaHkq&p>%Ko%kK zt#e0r^n7&f8%}^R>%oq9bHvCTT#gjPl-+%liq^F$9{LN78Xb;scO6yn*KH7|2`cy^ z?^Q=^bSY+f@K4k@Ji4Z2|7Y|k<<6ci&<$^yKn-t*IC-;<aLA5K5O_Ldh=GoAE7}! zWR<5U_AnZ?nmk|5-D9rtln9Vag92OKm6pG(j}3flV}6T{`K-<-!go4~W|y^LXC8vA zj>LlT?%3o$9{E{)!RW-A&Bcr+VPvy(+XVrnA0L&%@d)drRR&Z)^QI_=lNe8P#WQ{j zakCauxFrHQo^jSK$wLStcdXG9rEFC2m z`q&%Gi2|2x>j$vxWn`cI(vLro@hT+04DJyT=ni~zzJgJ49dvCtn>JN(Ry0f zNv<9#UuH26j2TMXI<8~4s2X7xN)W=Q>A-wKUB4C0h^_l>fX<+;SiQlycb_$$e%#gd z-m{W9ds!XW|7+Le#?p(LU&@97j+q4tLC;{O1S?GDNP<+QV_x{AyX396zqAfnPo%2m zmm#lrTQ0`ye=C3zf#lrFU}eTzkpi;kxu%ETvF}z@>s}+Fi0ii$Vl@@V!{vy;m2N4n z;Jlx(pa|z<@>fg=lH1eQd&aH}-jsWYR}$!NfBq3`slsXN{aj%}PS?L4a(%_{XgK`O zk*R_EGu$^t|D%-q4!4rn9p3nI`lMX(k{$AknAS}iIlcG9DJ~>1gPssnLj9=d9R_KVlO@a{JLWKR!3- zvXqRElFnn6E4z~`o&|Zlbp}Xz%S#@g9z3RRe{!n`>%R3tcFA^-mt%bd+07u zqXNOegWzD(aXq!<>h~w_7qEImiXxi(Zbwg`+b_dzr`FvnODKHdArBm}e-hnGnq61A z7Jau#+)3h@`|QGooV_t13~#fI!|j2-Z`wUg;+EblxvgcJWv-?)+3}h(MdL*6O6myf z+6t=$ThE8~8QUbeMa2l&c%~X!5cudMoN=_vMcdxnBxZ0z9dCUa4x2BaQebpJT1V$A z&4}LBcDTu!P$YKkh12_>(Nm)%x)QQ^lbA?|XZRv3OyZX{OV6#qYC|kTLAjN>;Bp?g z%YD7oGs|c{-7RQdce*Vy(po^zrRu@z{L_BbmH|ncr4)bfuRL-`HI#`DS!4vG0f90G zA0td)kdKUx(08PI$JXD z-8H=70ExZU6OAE-Y;7q0O-lhF^{Z(g0`t)Gbu#t|PW;5@p6!j=9-h+Y4)VvDGIpF%Yy_f2szcv$7?>-R>V zL|y<}Ixt#-`&rhJyV91qhiG5Yq1`2kPIa5xdNyPe+|WO}SkD1ftXh$$J2)goT94cN z=|*h09XBtk_ATc1lv>I0XJ-D3Y2)*e!?fGE34Y_gHj*HNG)-wjg7>_Zab-_{r)7`R z$zI@G?>RrFd5%N6sZ%hj_2Iq*-IeF}g$gG;`Wp_RiV(ddMV-3yaql&4NlkXKkCS0b zr6HQROt8mE(9`s@5(sjc(_D$m3g74H=70sWhbT(BycK0<<$GPnVDC>E!Rl4cE<_`6 z;&WC{1RTYA&PT1pa@FbMTDp}z+(nslq+V56)_jTYDu4RiC)wgmI#H`G!_sU4vHh7) z_d!^=CKKc%xJ?8A0usX-U0Jd6bioC+DAI0XZeGr>RaGs?qzCGiQlW>L#)=h#sWr~; z54PXB`T}M^^#to!P_h_UtRFqwuJ#?=&>;M|*NaAl42i^*TRG#rI{D+4o{Xv4q&B@U zSN@qkM`<5CXE{B4B)bS*vScLAekXn7hiF?4TbXa-B zm5_N>>^4-E=Z+i$@vLZd7mTMZvIsnV%WlI;)-a-?6T0S5dBm>1J5T6kgIBuJSL^oH zr>Hdulc!1u$tU@=s6V2^^2RfLC@XY-q{;67Vwk}_=R(Qcu^A@Z6lliE$xu? z_9c^L_W2>E#YT+_6r=^gu@m31p<`Y!El%KUs@uAk6oY-x&sNi~sDv6Yj0%CB*e8yl z*Whc`-`b*f_N3m}{wP_yQZQ(GpiKm*HwcQDrR0~P;b@J=<41d`?gDJ1C;q3SfW*ix z%_I0GYQ?PcE9pfC3E5}WQCyGw}M1VmE z@geTpD23GLAJ@pz!h3`BXa5Lbs#mQtF)KPU0sP>ZGVA408d^wu{&G2DssZ6tz~JH^ z&ftrd^&C#`*d!-aoJvuvq<89G!h!W@^3K{RjcYh(?aI*d zRp+wV_2(Yw&a8(?>vwA89O$D>+y@eP)IspA>5&}cvg*}DL%nXQVrQ@tvf`t_4`%B( zb1ABR#aX!?@*#7RGZ9%E6O-vl73WOvNol4l;R)8>>e{r}-p*a>DLAUJAz5G3F-Q!g zo!%*BlXa?mhR*fiDU2f|W*a~+tTn#rP8`cx!;AqYCIB#H zTmYQ_Tr~>w@dSWt*8l|o05|}qBp-l<5iyzoB8(dVATO8z2;<50r|bpuziaQjfcz)^ z(~!OfXkGXAzvmz1?H_PbP5CUKbqRKj<dOIM&n3I+1ul{ypWK7Hu7FITP4$eb|84aqB0nAJg2r~`vGE#M zvfm2iJEa=S!G9^e@Iwoyl$sSu!1eCeL&v4h6J*GL(EgJBcVKb>Jk-)pjPAL^?onY?>PBfPQRhb)X%m9BMS2<346ey1G;OZhF#5 zA#SZg?CZyQg$(wnAm?Lob$Px^s5cZCwU`cgt;eVL&;icfM_aw}!~x&bJ3t3S(Jh|(tlpv4J~Td3(*yOlz8k+T2TZ-aUWX8Op8a?eu-94?peP|xCqY8V zZC<2K13tDc|B+t5 z%&n&_pDOBlaFq0&^>j-e?w!G;_D3kKH~ZsNXguKj@EK)URV}aU#k+Tn>#wI>OxAa~ z))z5$z4O-8un%P*#{*1g)WBV(J!{}Ph>YSOH92H!CFcT!g+$}H16{f=g+{vSxq6p=(k1rpM=) zcQFlQJZ_5`-*nprG*E|v#uNjhmO2!&8fxb6*6=CsT0JVcrKj(1;BG)^_-~|n7z}ZA zLEyCc(P2C2jVQ!HN)lxo`;-E|*sa(RZA&bnd0DT!oDFL{Tee*ei??_}v5#?#Swr|4 z|GqdlhbSU~HSYi&cl+#F@#-S*d zdWX0|Q-S3u_BHa+ySy9`m}k!BrY_%;(I+bw!%Y!QvkPK(H*Kh8QHKc`ePjEf2~poH z2;4!z@~-S}c}k#HXaP#TIk=m5yD-s6(;{S^;pJ#+g2cm+;TaSEusHL8*ovv=q`GY& zqkkg92sMK0LkN;E*>1>^=>pYo#wU^Y57VsZ!M1)5!v_1}n4R70eTUWjtQsWpSb`~Z zptZ@J#!0a}P?(r<{=}wbD{TMrX!p69Iy$gAo}Y(s@G%k}Fx@*B@MMCzK+39#-x>Pu z&~hS<%~Vu`szc4!<0e3PIm&*YaqWB7bDA4Y@nV!+S1#|!8i0@8IlErd6o>*=;L!=& z?`u>xnYDzvR$ZQbGgs$#+^0wv!+MmHmbeOj`H-?ZQ74`lk~I?iLw!^U#0&xVo6!c? z`BmDXSUSKGgQKRS6egl>QJ-zSO^a)A3q77);!}}A2RvMK1s}Uhi%#`a#ijo9SnHrS z?KgWfPSR{mgp=dWT8s89t-2L8jDvgx3rP17qdF=HgDSQdwrXhquyPN`3Ug=@;$?&pmPem1V3-n)()?R`4W$Df?-i0DdoayKDkWj%%8 zr+?$4f2B@dgbBU7uhq;yq#H51EUq)I^B@oUkPd*_P!ps-o({C=yvnfKZ96Q)5_nLb z6O`6chi#?<>|qEoe|rb%01e73p`HtL;E4T>J;ReY>eDrS1quxGCFdcGqWRh){K{)f zP4;`FHi{CsOEVk_Zn+HFccjJT`u2}KB_u&b+L?qcY}Jz2>*rqt^8@%uh4Jh|MP_+ea8UsH|R{v-XUUglh zbU)6dG0^kd^ywnO*J&m)ZJojM3pjC%S3M{=SDT0uxE;`e{Vr4enfr(C-LPN43UXU< zwwA)5v~!n4%TT{yei*Byl{%a9rGFbzgt7pWcJ4(h-BItQ>NtC@8Jgd^ERz;|gC}7+ zY&uL}U-Cc+wBKnbTO^&L1MwFclnA=IvC?YGA3LQn{LP0ZgIQ{V(ql0(Zx?V8S$b+? zx7Yh8QCO1xo3g9$8x>jE8TC{_BI*~dyMSb~K3PkgUu^p!ip!jysx&5FGJ4j&{eosE%K7zpM3iMs zk#auYrApIbaf>f6YNn5c-0#xcKqK|ly%EyXQXB_GAACr2)Gr{)sCKGk zsukMG-SX7xMibaYiRD^d{XHz1Q8TOlL&aKot!*$>?4!%w79Y6Y@~fR?M!C&PJgr}@lik8HZtFS0O0KX|a>u;6ymUn;wYVs%h>WxvS;Hwf zFKN`utHNKH`S8=$V*P}pWAxAo?n?-jYmiR6O~o2cUj4cB0Zzzr0a^Wd#p7}jvEjPF z&Qh(q9lt(Wb(4q6LzM8x+7UiW9<#f`=#Ui-$Pi?8dKwxCroT)4PIj&_|fx) zxjR`<4<&>p^v*Ut43-_g=W7AW?iaizr0_hKx;)A_7`$#e0(DPInjs~FQ`ghC_tr8@ zs_rj@@fvKbLtB4q+pa#g5E|^WEILi(w+YW%icWwJJry3Xw!DJRMb~yV5}alpt5Q@+ zT%!@#2d`@!mG8I)@?XE7U-)d>bDr2ZNaHjtT?xT2ydo9Dt1>u?xfQ1@k{o_Ml0Lh_ zebwtnQ>F@DVyAmVUoy(Q0c;~#uSM8D2sSdsg-V}GQz$<2+WlTPwixP02d0wdntoBq zA}V6Hb6?YB#)@m*lo|>p9W1`@<*459s%e*cV{TCXGt*VOu~^QDu{(zMSVNmX`=vq6 z5cqIk>@13iZn><_FV{Nw*nZRJ>-&h1remXJaVfyC=#@`v51F*KZn>oEt7abox77Co z58zQzbs$wRp}`17+d2~1>619b1R=E8NvJ$Sdhm=6V32#=j1$E?MJSG29OT}K zeV8SKXZCQtc(&A&8X;?5Fq)wIy!6|b)S(VL@ZHx^b4S3Evu-LT<5{>TokE5W0u7!*Dy zIA#f>;!9iu^bzN1VGDJ-4&q98PdFQ%(gX^1${~VYKDUGlH}Hm!c}a#i^!4D8;a1f`3OD z8y&F7)H%(1Dk#(D2Cs!*0p_gYmdPcMiL}1+4BVX)Q-PDEv(vxBQ(ev4|1q}PSgufN zX*uC$s~4F};Udkn>!!lbg_qq3JSO8J6=pCI`GnG_xP{)a0^bpo_*!-Yrk&4z`|TIi z9s7NOtIH22dehgk;Obabg_X~jCqJ{wf>%5YC4}(m+2>Uvp=J^xQKI0jgOe03@Ev!& zzA94i=-9_&k7fb56c7$5$a~ZFS2VU9d<*tr=3%T$fp}EUN z7}Y+rkF4nu{fb{gbF$3<~xJK3t##h{ba0rBsTkxM)FoudMS6oB(mEa9Z30& zlB76{$$krVG;j#o=bB?IiR%Aw*|nx?nsxN5#>(xKSTP3|fjl$)fNx1s#F^Z1$^bP#9lNY%2;rd+By zG*V;u(ronTfcf3PNw?!V{&C*s<@&80Zi<;L9X-4IO2c6R4DX?v9 zt=_d+su`jZ*{B|Ll707D3)Il%2)H2s%de=W!8qArL)a;fD-3K%iWY2%N(6fRZ zDo2T4gRFZT%tS&?q6=Da1tjA~Z>qF6V)h<1iv}XHOTb981G$2qls0G|_bOuAN4iRH z&n9$~vhj%&>G&z#ZL#hGG|PXfMiY;E6`Y2w{)qS7?Nl?J9OY|5*>@6 zVzPxQuXf%{ynJE*zP!Xg0-F|VkOLEnC5R>Z=*bZCUW*{|Rr18%D_Ciyhua0frcLfV zhYYA8*qqwTgTHCY1mkx0(55?N%B4aR{00t@Pm3tfkJ6|d^Hn-urs)!-Tqur#lha(P z&7&rn_H}tc`*K7WYR2Y+pp~*y;eK*Esk#dG+NfkZ7ui0&_!bY+iZ`+5)kZx1#|~c1 zqr$M`%Ols5<3p}={j|<|{`_bC=-%Y7a;0s~GT)?rq3P;*v?%JxRFp00Fqn5S-sC-8 zG4^q#YV^|7DjtVHh9x~JysnilZMSb{mn1a_7PRZw$nA3OAQP-7C$i+ofECtQ;O26_ zcZd1i__~)UxhNHCZj?2-)_hbwTE)70BsFVzGTi>KRQ=U2E(;T{jbn~Flhg{X?D2YI z6Q&qKK!`-y-u|G+FqJ`_ry{2zqoFC72? literal 0 HcmV?d00001 diff --git a/Modules/Sources/JetpackStats/Resources/Mocks/Avatars/author6.jpg b/Modules/Sources/JetpackStats/Resources/Mocks/Avatars/author6.jpg new file mode 100644 index 0000000000000000000000000000000000000000..befcc31556b525ddd80034852d48a095a7736103 GIT binary patch literal 6649 zcmbW4c{G&o|Nn2smVJpNOoe1mwg{7CO9(MpqDYp(WFH#l9m&RM! z>|3_6W^2Ye%=Xj!`#YcSIp4p&=li^``?_D}e!Z^iI*;pqo$I`A+5~ML;J&SIqz};1 z0f6Dj1<)3NJ9-HBCjek#0!RY@zyi=kxdRL*)=3B;baDd#D2EOJojmFOzRRKikJmE? z^xyX1K-v~??XDZ#AMWP{e|la?{t9sIrjZH5-`P&|uPyPf$vq+xH<<`jG5r4L)Z0zs z%`jRkz{3J~0-hkcivT?j9f*gH)&YP|)?}pnSN{$>>FDS|42(?7ET>r6P6BGU0eU(R zh@Jt&$oO{|y3i9pz`(=EdqGi$iO>8I^F^Q2SDz<+U=h1n`IX;d5HGIe|u|DVYI1NPrs z(}30dGbf3f_lQ-8(wAJI-Gq5C@$1Y$gCY%EMH z|MC5=4cg2}S~An70Co`FNiu>p2vAzL!xhHf(a?=MVrtt+LhVyvH}Icn57ulGO~&?N+|En%;Ny5frAHlLLtu0vJ~tk&C?jvt<$& z>Stu*9NY7wE8bP2wbxYa%N2vCCGm|)Ntwmp%JOa>`2G|VIDq(;YilEgY6zMRoar6b zu6N%V%X*N~BjV1Sz3ZzOw^Agz#f2|!^3-I2PkPUExGU-FHTIzXF+|K9fhNZ171|tQ z1m<`)%aM>z!~{9D4)cfo&essjBB(($CulEO**&$9UvoTH5A*w2HA;3oeWTp{w(Zri z-5BqMWO3VgM(JNi^Ou=QrpDWYl z3u!+`@%q)Jh)d*Gwa#bKm|zFc0~dk@;+0`k=CVKwYZKA^0fZK9gd_JbW+mkm>EAbl+Q$swtHLPD&m?F|yzbh87&VngxVG0|x4U8dOpGDpy zl)TLuEE7*#%@pqJc9_)KXm63-L~(t?%y1wekyK8i-$5+#dRbUMRXcgXUoMp8N$u-S zabFp41NMAyYO7~ZmrNZUG#R6y@8dLIU}RiL9t@jsta0pAh^Agbx2mz_)TTR!D%Yl% z1o^SoG3iU(={;R^w!)_|*+6S!7lH$K+ljE*R^tzo5fbp0HS1FH|bB}EZ3-tddlXZP-u^$`R5YZ`OJrWaoh%X zLt*%IDl0P{v{1O&D~FCzrm&v9fPE6q_Of5hNyEADBG#S;%*T#R-I}YmKi7gw% zX3n*IGw0;`^2B59EAD~mf#&7_OR+)0$+nW<8q=N;=Sq44SY-XH#d-`FGRI31Y4elr z%XynMM@Ng8=#aA6qGP=r*{_w|9{U$$#8%h!543DoaSq`b5$=@@?+gIfj3YA~w9^Ex zPq-Qkw)w$2YGIbK+5`ZzwI zw$lLq{YI`!ZBCtc*`ZMu+FOI0oun~{FIDypwKY(ZXyOa0?kaCU`Z(zAdOC3+Ndy>f zFRJ~A+EZxeC}tAgRgB8_POAUq{s@iz@gh3Dc&B0wD|R;^3WZzLod&Yc<;A z9-bNX8rfgF^811cDQW=|J;&%-t{J0g^3mSDu!o=<%=axnaOxbS09m(aHW{+o;dq8B zIrrPLrx!>xC*s`|L*24QgM*<6c)BM9^Kxb2Biqz9~kya`SZUpCPO!#Ocq(F3$QLNtQU! z?r?o1|GmWvdiNvwNO;^+U8SoFF`(?!BO3^rPi^r@D&fX@h(K|XyKVA}_p|p}s!~Xz z=@pRnD%Wd2F?^rHAXobL;0*zT0?<=!mHc zpYIA8cg3?Ez=(lPJRAH0&Ig~O;XjnzsJmv^WuG%HD11B z5-O*RR55T~wZn2ucVpfe9z}#$fdwb>HiWqqg%VdC(u+F^?cs+d(S(}Z+AkApJg71o zX4=q_wiJ;D7!gcssb|CW*eXB+Wuf&XdBhq0;hg8Wtm2PFD6pdsV`O7O0ag4x>AI_; z41LJ_bH{R2bx;@T6|_zuZFo}o+!Z!Ii;6c^Y$odg&pfh+2ouy7W}7ql#6N~?!Rqat zv50c?!gy;l^Ma9`ox7Tp)>TI_{r&jL7zeL;EclP z$*dz1gI>GhQ0T!xEpGYeRubr`bBrnUa*z@znc1O<)6Ud=XDzpU`Zo>W8Wa|6^2ZIO zm8~UF&Lf?OlzB5Yqz`^8Dhl}^=ex4%q4nXEw0)ij{LCgStsO#MyYX9*^#z*!c8c8PYXJC5rm-_)FkIX_~)IW>7^u)cqvT}SMnr#_D0 z4x;PJ(ochC$;N@78|LtphfNaER%;q687mQ!+KKS4)ivg}x#`^lYy9|gvQ92yxTBb} z&Rr8bb@%OKgIFsncck~jEIQCi7i^BoANI3FV0_Q5=6Z$X@E^MLJ6MD*k00IA6)4@C zgh=q6@r9KV3?iii%jZlOkI@z(-k!FueXSl`lg2LS^G*@ir4xDMs&X(hp4cKwB;Bfb z-zD_qRqondmpiszBk6vbq2tzWx%V78pPCJ|_-*iuohEs$gvQ@thc=BR?~)Ts!_<&B z$EfGm>ayvHF3&hVmW47@+S>PjG`x`cVP`2oxT@q~>h?9vrxq^v^x1R7(7d%-sb*t} z#aO=}Mt;#uN?x8HNYlHM^3ZuRnVCO%etIXoh+)n8dJwNnuuwYU1RMoB>EG$bt4AwE(?@&Hf~%T8;@?Ivkn{ zrWUlw5wucE+xUG3Zm;?u39WN!azNpJ>==tQIj!0-sJwvzfXlGf}2Fy2=ecIK6LKJC87HpN*;MU`lMl%ik=^y#9TuBEMp~D7SwHw zDkYD)#s_p+1kc%b$J@ty&ZFqZ)yp-ChWy;5&G1KKbN zeS?x1Ci3e+aCPhZftpY_e(~TTo;m80O>Uh9G&>d5o`N zN|C`l=H%E?4y@wWcu`uF&LacbsS+0Ev||7bVM`pR{HUzz_=tr+5h{rsME2ftkLoEi z_-TUCf!oF8ThfAtI==TZ!%GrFsH`nw$UCFU#F*f6-ZAa-)-=Gh>Rm-01FF{$oyRvK zTKEBj{kF6I*4DT9RiRr?Z)(}*A7bD$v_!_@BU|&AqG5aRPrpMU<=w+!=kYJ2@G0%J zG=LrQq)&TR1l4VusqvtcP_O?bZCyv$JUGE9{`Bj!MvKXMVao`%))olmIazT51wvH) zMw(8EWLjr*)GmZ;w#$j>H71|V%DXlbN63WkraBXX3-vG$43C8;rw`xLETqd+bcsHEe~^fFZ~O zG8ZB0fR3ZJ zA`D%OLo*_^ieZZu!)fYUOJ2R=vZbY5D-dZ?RBK3g8zx-zES!h9Xi@povus#FWslJM zMRfZHnfVe{%-?zt>bk+kBN?RTvX1OMNo`%qOKl~xJ5eeR8A6mQ(!ErJuR7Ovo7%R} zRIDH;!Ln<_-po*0ih4SKHJOe9Kyfw&6q5xhWmLc&%Zs#w$D?%P=Zzze9w(XmI=v2O z<1+OvOmh5$u7#8NLG5!QWX2-u01c>a`JjT14A5CZM0B3JV-n=aH2`~93gS-@Ob(>G zxpdhx#W8N@KGCv6DAGENp~8#mYn(mUMEem;uXR1#aK2aJMbaY}X<(9CX^Wh72Q%P|EicS;(+z0y^@^vu7nw z?7XzuoOeZUNavBB8~iX6t)YA*@-5D_Iwoi+bL7uSWKn*=8m9hu!kZFqoPXo}jHwTL zW;sB*M=(>_u268NQF427&QTK8y11~}c6w1-rNCV+)ZuXJ5G;$HvbF=qO2I>JhwX`m zKqoX-x47~K{FBSt;<}nQmK9>=sx@Yb`RikYudV9nuVK_jsb^q!9fEHU?EIsF!)m7_ z4}zWfnd@mIlmG&x1I|UhguhfsIM->xP(8FT+nV*!z={6+K#&t~Jia?lvY^h*akogX z59QZ+xfgbH=y>t*oOYfF(k=~#6nLS&KBfU4>i)fbG6zUJW$fG5MH6F-iI4$Ry$0|h z)Nk<@bipB=Anz#XHe~XYZ={_H?oLA$fR z40==Hm)#MT$UGEM*U6#lm|^rWD_Q|6eberwy{zfo6*_!xD zEz+CHjmVI48k)WSzWGzTXYz~qgoQ(!+RhhFSDO}#F?lt;suqu<{l=O?a05moNF6WV z)^^_(r>k-QkOtGUyD=ez5|=^S&c?tHOxJ3j@j1Etv}Wr>yk@qD#?FQT3I0#bmT~)# zvlL6#R-PYqU3+jJ;3|8pn}cwdPq!lG;W|eZoR3nypFGQF2#HIB|KM_dAQJUeCJSa3 zQzK`zec?QJ0W@(aZ-mOUrlF0N{TftNyy0Nib5e7A2`OUVQv{-Nsv&-pw@b^Gu-5*^ z*pt7`sBF=S8f@@fhZ=8=o7a*qJJ0jsD~a!-4ueGUp&UiTUfg)+##f;yQzJmM>efA- zN$!W2G1k>Yc)={JdbU?6H7;MOTki!u-5#!aw_RJHYWQJ?&*cM*bvAFaiL~k9=4Wz{ z<`K?am*~^Dy-$YxSPEl6NRPKd!BZ)fX_SAsly)ZeL?b4kYtZK{S75dQZSnG3BZb$# zZ#iBt)o1GN7{cXsJkoWi3&)DgdmUUAVVFI(Jgo>7;D= zkgpNH`mNqKjxO6?x}Gulz4iOLvt&3Vs%Y^d)~4nz1q%q8i?;6I1H}nP_q6 zb*K)G1lRMSEPsY+r&8Rg?U2v4?+McfK7&XDT%W%9*yrYB(;SzlLPlFx7@{Ogy37aa zRgP8#mrpNW#^Ec7S^4Q<33KS9UnEZ9A1>~XHU?b$=e*~ZP_$914Hh<+C^XlFiOCit zR@ld=#9P=!|ICaGzak)hEbZd}o;TxF`sFY{cR}7c{-ipEy@n->%ey4EoetCCMA^0z zLwzpx1?J^$fp%4n?LDqpM~c;8#gQ_F1Nwf?=5}2tT3AQ*SL5)04$Y#)Ja_XV683i` zUzSTGtX<7~%C+h+;HA_uGt)0KT`0#TRU)E75`XnYICehjIav?K0VA2U3kCFlpk#bp z-`yNk4rlWRD_40jJ1GQu(rq}_q1jN6|Af;3A(RXHIaO+G6Kp)XKXBFt;cC$`?AVjG z-D}=5!k=*ick60L?OLTFH+TNaU#eR~wu2aACGG=(*@}{dys(ULY&}*j4)SQwPAG$O z4BNm@sZ3XE)>-oAq-D)9N3vckcX#5~U*IptMpO=N+RI%cg=u8Dw*Q&zZUNWJSe$(w)zME%B>?8@;83dHOK^Qe% z3W?c^2357#=HWkYe%rqItmdP4Rpwpdq<8>tzPH#H@!nJ`P1hc6G3w7c$hqwgW140Q z3S5&L4f{A9RO$V%J|atPT19p%Ul8V5`rzIe9btemEE^%SkobZsy9WNu5T?MVtQK~W zAlLWnuA!4%bd#Lhp{jL1bd@`}>Q9n+>f3HnL9f;Lu}rZa%Ns=0*R8OR>zQ`T#>eBw z>tPzY3nYrrynHcCuKr8skDG2&kgI3I9w+J2)eHKTUK1)@{5&3IO>eakiJLCnp z;!hh!1Yz4A*o>p-Q&%lUTzz+|ZVa^xOT}Hhm3w94)R%gRw|M4|&dc8|lSiis*dyWX z7r2#dG=6R+3^~~+kf&H17j7n~vYQ@VzC5UXzO3n=^2LPEjk;G>&7Vi_wN5NqOLhr5 zHPjF89}3ndjVLJqyRlOxP4ad6ccFo<#bMvTRWu-msx@VgDA64&{k-T^r02%F;F2_J zZF!z!xr8N`RdGf8jQXC+DjNaXna$nYG}NS3)CW72H92;cMfZGL%1$P@K^^m2^Xw;& zz*%J(cOO~lt;!DXdskQU9>yg)JER1Q_4 some View { + let formatter = StatsValueFormatter(metric: metric) + let trendViewModel = TrendViewModel( + currentValue: chartData.currentTotal, + previousValue: chartData.previousTotal, + metric: metric + ) + + return VStack(alignment: .leading, spacing: 16) { + // Header section + VStack(alignment: .leading, spacing: 2) { + StatsCardTitleView(title: metric.localizedTitle, showChevron: true) + + if let dateRanges { + Text("\(dateRangeFormatter.string(from: dateRanges.dateInterval)) vs \(dateRangeFormatter.string(from: dateRanges.effectiveComparisonInterval))") + .font(.caption) + .foregroundColor(.secondary) + } else { + Text("Period comparison") + .font(.caption) + .foregroundColor(.secondary) + } + } + + // Metrics section + HStack(alignment: .top, spacing: 24) { + metricColumn( + label: "Value", + value: formatter.format(value: chartData.currentTotal, context: .compact), + formatter: formatter + ) + + metricColumn( + label: "Previous", + value: formatter.format(value: chartData.previousTotal, context: .compact), + formatter: formatter + ) + .foregroundColor(.secondary) + + Spacer(minLength: 0) + + VStack(alignment: .trailing, spacing: 2) { + Text("CHANGE") + .font(.caption.weight(.medium)) + .foregroundColor(.secondary) + + HStack(spacing: 2) { + Text(trendViewModel.sign) + .font(.body.weight(.medium)) + + Text(formatter.format(value: abs(trendViewModel.currentValue - trendViewModel.previousValue), context: .compact)) + .font(Font.make(.recoleta, textStyle: .title, weight: .medium)) + .padding(.trailing, 14) + + Image(systemName: trendViewModel.systemImage) + .font(Font.make(.recoleta, textStyle: .body, weight: .medium)) + .padding(.bottom, 2) + + Text(trendViewModel.formattedPercentage) + .font(Font.make(.recoleta, textStyle: .title, weight: .medium)) + } + .foregroundStyle(trendViewModel.sentiment.foregroundColor) + } + } + .lineLimit(1) + } + } + + private func metricColumn(label: String, value: String, formatter: StatsValueFormatter) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(label.uppercased()) + .font(.caption.weight(.medium)) + .foregroundColor(.secondary) + + Text(value) + .font(Font.make(.recoleta, textStyle: .title, weight: .medium)) + } + } + + private func dataItemsView(for chartData: ChartData, metric: SiteMetric) -> some View { + let formatter = StatsValueFormatter(metric: metric) + + let maxValue = chartData.currentData.map(\.value).max() ?? 1 + + return VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 12) { + Text("Detailed Data") + .font(.headline) + + // Header + HStack { + Text("DATE") + .font(.caption) + .fontWeight(.medium) + .foregroundColor(.secondary) + .tracking(0.5) + + Spacer() + + Text("VALUE") + .font(.caption) + .fontWeight(.medium) + .foregroundColor(.secondary) + .tracking(0.5) + } + } + .padding(.horizontal) + + // Data items + VStack(spacing: 8) { + ForEach(Array(chartData.currentData.enumerated()), id: \.element.date) { index, currentPoint in + let previousValue = index < chartData.mappedPreviousData.count ? chartData.mappedPreviousData[index].value : 0 + let change = currentPoint.value - previousValue + let changePercent = previousValue > 0 ? (Double(change) / Double(previousValue)) * 100 : 0 + + let previousDate = index < chartData.previousData.count ? chartData.previousData[index].date : nil + + DataItemRow( + date: context.formatters.date.formatDate(currentPoint.date, granularity: chartData.granularity), + currentValue: currentPoint.value, + previousValue: previousValue, + previousDate: previousDate != nil ? context.formatters.date.formatDate(previousDate!, granularity: chartData.granularity) : nil, + change: change, + changePercent: changePercent, + maxValue: maxValue, + formatter: formatter, + metric: metric + ) + } + } + } + } +} + +// MARK: - Data Item Row + +// TODO: reuse the code with the horizontal bar chart thingy +private struct DataItemRow: View { + let date: String + let currentValue: Int + let previousValue: Int + let previousDate: String? + let change: Int + let changePercent: Double + let maxValue: Int + let formatter: StatsValueFormatter + let metric: SiteMetric + + @ScaledMetric(relativeTo: .body) private var valueColumnWidth: CGFloat = 60 + + var body: some View { + return HStack(spacing: 16) { + Text(date) + .font(.callout) + .foregroundColor(.primary) + .lineLimit(1) + + Spacer(minLength: 8) + + VStack(alignment: .trailing, spacing: 0) { + Text(formatter.format(value: currentValue)) + .font(.callout.weight(.medium)) + .foregroundColor(.primary) + .contentTransition(.numericText()) + + } + } + .padding(.vertical, 12) + .padding(.horizontal, 12) + .background( + DataBarBackground(value: currentValue, maxValue: maxValue, metric: metric) + ) + } +} + +// MARK: - Data Bar Background + +private struct DataBarBackground: View { + let value: Int + let maxValue: Int + let metric: SiteMetric + + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + GeometryReader { geometry in + HStack(spacing: 0) { + RoundedRectangle(cornerRadius: 6) + .fill(barColor) + .frame(width: barWidth(in: geometry)) + .animation(.spring(response: 0.4, dampingFraction: 0.8), value: value) + Spacer(minLength: 0) + } + } + } + + private var barColor: Color { + metric.primaryColor.opacity(colorScheme == .light ? 0.09 : 0.5) + } + + private func barWidth(in geometry: GeometryProxy) -> CGFloat { + guard maxValue > 0 else { + return 0 + } + let value = geometry.size.width * CGFloat(value) / CGFloat(maxValue) + return max(0, value) + } +} + +extension View { + func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View { + clipShape(RoundedCorner(radius: radius, corners: corners)) + } +} + +struct RoundedCorner: Shape { + var radius: CGFloat = .infinity + var corners: UIRectCorner = .allCorners + + func path(in rect: CGRect) -> Path { + let path = UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius)) + return Path(path.cgPath) + } +} + +#Preview { + NavigationStack { + ChartDataListView( + chartDataDict: [ + .views: ChartData( + metric: .views, + granularity: .day, + currentData: [ + DataPoint(date: Date(), value: 1000), + DataPoint(date: Date().addingTimeInterval(-86400), value: 1200), + DataPoint(date: Date().addingTimeInterval(-172800), value: 800) + ], + previousData: [ + DataPoint(date: Date().addingTimeInterval(-604800), value: 900), + DataPoint(date: Date().addingTimeInterval(-691200), value: 1100), + DataPoint(date: Date().addingTimeInterval(-777600), value: 750) + ], + mappedPreviousData: [ + DataPoint(date: Date(), value: 900), + DataPoint(date: Date().addingTimeInterval(-86400), value: 1100), + DataPoint(date: Date().addingTimeInterval(-172800), value: 750) + ] + ) + ], + selectedMetric: .views, + dateRanges: Calendar.demo.makeDateRange(for: .last7Days) + ) + } +} diff --git a/Modules/Sources/JetpackStats/Screens/InsightsTabView.swift b/Modules/Sources/JetpackStats/Screens/InsightsTabView.swift new file mode 100644 index 000000000000..52ffdfe5f704 --- /dev/null +++ b/Modules/Sources/JetpackStats/Screens/InsightsTabView.swift @@ -0,0 +1,26 @@ +import SwiftUI + +struct InsightsTabView: View { + + var body: some View { + ScrollView { + VStack(spacing: 16) { + Text("Insights") + .font(.largeTitle) + .fontWeight(.bold) + .padding(.top, 20) + + Text("Coming Soon") + .font(.headline) + .foregroundColor(.secondary) + + Spacer(minLength: 100) + } + .padding() + } + } +} + +#Preview { + InsightsTabView() +} diff --git a/Modules/Sources/JetpackStats/Screens/RealtimeTabView.swift b/Modules/Sources/JetpackStats/Screens/RealtimeTabView.swift new file mode 100644 index 000000000000..c21fb0d985cd --- /dev/null +++ b/Modules/Sources/JetpackStats/Screens/RealtimeTabView.swift @@ -0,0 +1,79 @@ +import SwiftUI + +struct RealtimeTabView: View { + @State private var dateRangeComparison = StatsDateRange( + interval: { + let now = Date() + let thirtyMinutesAgo = now.addingTimeInterval(-30 * 60) + return DateInterval(start: thirtyMinutesAgo, end: now) + }(), + component: .day + ) + + @Environment(\.context) var context + + let timer = Timer.publish(every: 3, on: .main, in: .common).autoconnect() + + var body: some View { + ScrollView { + VStack(spacing: Constants.step3) { + realtimeStatsCard + Text("Showing Mock Data") + .font(.subheadline.weight(.medium)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + realtimeTopPosts + realtimeTopReferrers + realtimeTopLocations + } + .padding(.vertical, Constants.step2) + } + .onReceive(timer) { _ in + updateDateRange() + } + } + + private var realtimeStatsCard: some View { + RealtimeMetricsCard() + .cardStyle() + } + + private var realtimeTopPosts: some View { + RealtimeTopListCard( + initialDataType: .postsAndPages, + service: context.service + ) + .cardStyle() + } + + private var realtimeTopReferrers: some View { + RealtimeTopListCard( + initialDataType: .referrers, + service: context.service + ) + .cardStyle() + } + + private var realtimeTopLocations: some View { + RealtimeTopListCard( + initialDataType: .locations, + service: context.service + ) + .cardStyle() + } + + private func updateDateRange() { + let now = Date() + let thirtyMinutesAgo = now.addingTimeInterval(-30 * 60) + dateRangeComparison = StatsDateRange( + interval: DateInterval(start: thirtyMinutesAgo, end: now), + component: .day + ) + } +} + +// MARK: - Preview + +#Preview { + RealtimeTabView() +} diff --git a/Modules/Sources/JetpackStats/Screens/StatsMainView.swift b/Modules/Sources/JetpackStats/Screens/StatsMainView.swift new file mode 100644 index 000000000000..2f6364cfcf7e --- /dev/null +++ b/Modules/Sources/JetpackStats/Screens/StatsMainView.swift @@ -0,0 +1,50 @@ +import SwiftUI + +public struct StatsMainView: View { + @State private var selectedTab = StatsTab.traffic + @State private var isTabBarBackgroundShown = true + + private let context: StatsContext + + public init(context: StatsContext) { + self.context = context + } + + public var body: some View { + tabContent + .id(selectedTab) + .trackScrollOffset(isScrolling: $isTabBarBackgroundShown) + .toolbarBackground(.hidden, for: .navigationBar) + .safeAreaInset(edge: .top) { + StatsTabBar(selectedTab: $selectedTab, showBackground: isTabBarBackgroundShown) + } + .background(Constants.Colors.statsBackground) + .navigationTitle(Strings.stats) + .navigationBarTitleDisplayMode(.inline) + .environment(\.context, context) + } + + @ViewBuilder + private var tabContent: some View { + switch selectedTab { + case .traffic: + TrafficTabView(dateRange: makeDefaultDateRange()) + case .realtime: + RealtimeTabView() + case .insights: + InsightsTabView() + case .subscribers: + SubscribersTabView() + } + } + + private func makeDefaultDateRange() -> StatsDateRange { + context.calendar.makeDateRange(for: .today) + } +} + +#Preview { + NavigationStack { + StatsMainView(context: .demo) + } +} diff --git a/Modules/Sources/JetpackStats/Screens/SubscribersTabView.swift b/Modules/Sources/JetpackStats/Screens/SubscribersTabView.swift new file mode 100644 index 000000000000..6d05a8e8428d --- /dev/null +++ b/Modules/Sources/JetpackStats/Screens/SubscribersTabView.swift @@ -0,0 +1,26 @@ +import SwiftUI + +struct SubscribersTabView: View { + + var body: some View { + ScrollView { + VStack(spacing: 16) { + Text("Subscribers") + .font(.largeTitle) + .fontWeight(.bold) + .padding(.top, 20) + + Text("Coming Soon") + .font(.headline) + .foregroundColor(.secondary) + + Spacer(minLength: 100) + } + .padding() + } + } +} + +#Preview { + SubscribersTabView() +} diff --git a/Modules/Sources/JetpackStats/Screens/TrafficTabView.swift b/Modules/Sources/JetpackStats/Screens/TrafficTabView.swift new file mode 100644 index 000000000000..dc3af9d43b90 --- /dev/null +++ b/Modules/Sources/JetpackStats/Screens/TrafficTabView.swift @@ -0,0 +1,124 @@ +import SwiftUI + +struct TrafficTabView: View { + @State private var dateRange: StatsDateRange + @State private var isShowingCustomRangePicker = false + + @Environment(\.context) var context + + init(dateRange: StatsDateRange) { + self._dateRange = State(initialValue: dateRange) + } + + var body: some View { + ScrollView(showsIndicators: false) { + VStack(spacing: Constants.step3) { + overviewChart + postAndPagesList + authorsList + } + .padding(.vertical, Constants.step2) + } + .background(Constants.Colors.statsBackground) + .toolbar { + if #available(iOS 26, *) { + normalModeToolbarContent + } + } + .safeAreaInset(edge: .bottom) { + if #unavailable(iOS 26) { + LegacyFloatingDateControl(dateRange: $dateRange) + } + } + .sheet(isPresented: $isShowingCustomRangePicker) { + CustomDateRangePicker(dateRange: $dateRange) + } + } + + // MARK: - Toolbar + + @ToolbarContentBuilder + private var normalModeToolbarContent: some ToolbarContent { + ToolbarItemGroup(placement: .bottomBar) { + datePickerToolbarItem + Spacer() + makeNavigationButton(direction: .backward) + makeNavigationButton(direction: .forward) + } + } + + private func makeNavigationButton(direction: Calendar.NavigationDirection) -> some View { + Menu { + ForEach(dateRange.availableAdjacentPeriods(in: direction)) { period in + Button(period.displayText) { + dateRange = period.range + } + } + } label: { + Image(systemName: direction.systemImage) + } primaryAction: { + dateRange = dateRange.navigate(direction) + } + .disabled(!dateRange.canNavigate(in: direction)) + .tint(.primary) + } + + // MARK: - Date Picker + + private var datePickerToolbarItem: some View { + Menu { + StatsDateRangePickerMenu(selection: $dateRange, isShowingCustomRangePicker: $isShowingCustomRangePicker) + } label: { + datePickerLabel + } + .menuOrder(.fixed) + .tint(.primary) + } + + private var datePickerLabel: some View { + HStack { + Image(systemName: "calendar") + .font(.subheadline) + Text(context.formatters.dateRange.string(from: dateRange.dateInterval)) + .fontWeight(.medium) + } + .padding(.horizontal, 10) + } + + private var comparisonRangeText: String { + let range = dateRange.effectiveComparisonInterval + let localizedText = context.formatters.dateRange.string(from: range) + return localizedText + } + + // MARK: - Cards + + private var overviewChart: some View { + ChartCard( + metrics: SiteMetric.allCases, + dateRange: dateRange, + service: context.service + ) + .cardStyle() + } + + private var postAndPagesList: some View { + TopListCard( + dateRange: dateRange, + availableDataTypes: TopListItemType.allCases, + initialDataType: .postsAndPages, + service: context.service + ) + .cardStyle() + } + + private var authorsList: some View { + TopListCard( + dateRange: dateRange, + availableDataTypes: TopListItemType.allCases, + initialDataType: .authors, + service: context.service + ) + .cardStyle() + } +} diff --git a/Modules/Sources/JetpackStats/Services/Data/DataPoint.swift b/Modules/Sources/JetpackStats/Services/Data/DataPoint.swift new file mode 100644 index 000000000000..5801de292135 --- /dev/null +++ b/Modules/Sources/JetpackStats/Services/Data/DataPoint.swift @@ -0,0 +1,50 @@ +import Foundation + +struct DataPoint: Identifiable, Hashable, Sendable { + let id = UUID() + let date: Date + let value: Int + + static func == (lhs: DataPoint, rhs: DataPoint) -> Bool { + lhs.date == rhs.date && lhs.value == rhs.value + } +} + +extension DataPoint { + /// Maps previous period data points to align with current period dates. + /// - Parameters: + /// - previousData: The data points from the previous period + /// - from: The date interval of the previous period + /// - to: The date interval of the current period + /// - component: The calendar component to use for date calculations + /// - calendar: The calendar to use for date calculations + /// - Returns: An array of data points with dates shifted to align with the current period + static func mapDataPoints( + _ dataPoits: [DataPoint], + from: DateInterval, + to: DateInterval, + component: Calendar.Component, + calendar: Calendar + ) -> [DataPoint] { + let offset = calendar.dateComponents([component], from: from.start, to: to.start).value(for: component) ?? 0 + return dataPoits.map { dataPoint in + DataPoint( + date: calendar.date(byAdding: component, value: offset, to: dataPoint.date) ?? dataPoint.date, + value: dataPoint.value + ) + } + } + + static func getTotalValue(for dataPoints: [DataPoint], metric: SiteMetric) -> Int { + guard !dataPoints.isEmpty else { + return 0 + } + let total = dataPoints.reduce(0) { $0 + $1.value } + switch metric.aggregarionStrategy { + case .average: + return total / dataPoints.count + case .sum: + return total + } + } +} diff --git a/Modules/Sources/JetpackStats/Services/Data/SiteMetric.swift b/Modules/Sources/JetpackStats/Services/Data/SiteMetric.swift new file mode 100644 index 000000000000..33420740212c --- /dev/null +++ b/Modules/Sources/JetpackStats/Services/Data/SiteMetric.swift @@ -0,0 +1,76 @@ +import SwiftUI + +enum SiteMetric: CaseIterable, Identifiable { + case views + case visitors + case likes + case comments + case timeOnSite + case bounceRate + + var id: SiteMetric { self } + + var localizedTitle: String { + switch self { + case .views: Strings.SiteMetrics.views + case .visitors: Strings.SiteMetrics.visitors + case .likes: Strings.SiteMetrics.likes + case .comments: Strings.SiteMetrics.comments + case .timeOnSite: Strings.SiteMetrics.timeOnSite + case .bounceRate: Strings.SiteMetrics.bounceRate + } + } + + var systemImage: String { + switch self { + case .views: "eyeglasses" + case .visitors: "person.2" + case .likes: "star" + case .comments: "bubble.left" + case .timeOnSite: "clock" + case .bounceRate: "rectangle.portrait.and.arrow.right" + } + } + + var primaryColor: Color { + switch self { + case .views: Constants.Colors.blue + case .visitors: Constants.Colors.purple + case .likes: Constants.Colors.red + case .comments: Constants.Colors.green + case .timeOnSite: Constants.Colors.orange + case .bounceRate: Constants.Colors.pink + } + } + + func backgroundColor(in colorScheme: ColorScheme) -> Color { + primaryColor.opacity(colorScheme == .light ? 0.05 : 0.15) + } +} + +extension SiteMetric { + var isHigherValueBetter: Bool { + switch self { + case .views, .visitors, .likes, .comments, .timeOnSite: + return true + case .bounceRate: + return false + } + } + + var aggregarionStrategy: AggregationStrategy { + switch self { + case .views, .visitors, .likes, .comments: + return .sum + case .timeOnSite, .bounceRate: + return .average + } + } + + enum AggregationStrategy { + /// Simply sum the values for the given period. + case sum + /// Calculate the avarege value for the given period. + case average + } +} diff --git a/Modules/Sources/JetpackStats/Services/Data/SiteStatsData.swift b/Modules/Sources/JetpackStats/Services/Data/SiteStatsData.swift new file mode 100644 index 000000000000..a9727e31707a --- /dev/null +++ b/Modules/Sources/JetpackStats/Services/Data/SiteStatsData.swift @@ -0,0 +1,5 @@ +import Foundation + +struct SiteStatsData: Sendable { + var metrics: [SiteMetric: [DataPoint]] +} diff --git a/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift b/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift new file mode 100644 index 000000000000..67213e64e0f1 --- /dev/null +++ b/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift @@ -0,0 +1,282 @@ +import Foundation + +struct TopListChartData { + struct Item { + let current: any TopListItem + let previous: (any TopListItem)? + } + + let item: TopListItemType + let metric: SiteMetric + let items: [Item] + let maxValue: Int + + struct ListID: Hashable { + let item: TopListItemType + let metric: SiteMetric + } + + var listID: ListID { + ListID(item: item, metric: metric) + } +} + +// MARK: - Mock Data + +extension TopListChartData { + static func mock( + for item: TopListItemType, + metric: SiteMetric = .views, + itemCount: Int = 6 + ) -> TopListChartData { + let items = mockItems(for: item, metric: metric, count: itemCount) + let matchedItems = items.map { item in + // Create previous item with slightly different values + let previousItem = mockPreviousItem(from: item, metric: metric) + return Item(current: item, previous: previousItem) + } + + let maxValue = items + .compactMap { $0.metrics[metric] } + .max() ?? 1 + + return TopListChartData( + item: item, + metric: metric, + items: matchedItems, + maxValue: maxValue + ) + } + + private static func mockItems( + for item: TopListItemType, + metric: SiteMetric, + count: Int + ) -> [any TopListItem] { + switch item { + case .postsAndPages, .posts, .pages: + return mockPosts(metric: metric, count: count) + case .referrers: + return mockReferrers(metric: metric, count: count) + case .locations: + return mockLocations(metric: metric, count: count) + case .authors: + return mockAuthors(metric: metric, count: count) + case .externalLinks: + return mockExternalLinks(metric: metric, count: count) + } + } + + private static func mockPosts(metric: SiteMetric, count: Int) -> [TopListData.Post] { + let posts = [ + ("Getting Started with SwiftUI", "John Doe", 3500), + ("Understanding Async/Await in Swift", "Jane Smith", 2800), + ("Building Better iOS Apps", "Mike Johnson", 2200), + ("SwiftUI vs UIKit: A Comparison", "Sarah Wilson", 1900), + ("Advanced Swift Techniques", "Tom Brown", 1600), + ("iOS App Architecture Patterns", "Emma Davis", 1300), + ("Swift Performance Tips", "Chris Miller", 1000), + ("Debugging in Xcode", "Lisa Anderson", 850) + ] + + return posts.prefix(count).enumerated().map { index, data in + let baseValue = data.2 + let metrics = createMetrics(baseValue: baseValue, metric: metric) + return TopListData.Post( + title: data.0, + postId: "\(index + 1)", + pageId: nil, + type: nil, + author: data.1, + metrics: metrics + ) + } + } + + private static func mockReferrers(metric: SiteMetric, count: Int) -> [TopListData.Referrer] { + let referrers = [ + ("Google", "google.com", 4200), + ("Twitter", "twitter.com", 3100), + ("Facebook", "facebook.com", 2400), + ("LinkedIn", "linkedin.com", 1800), + ("Reddit", "reddit.com", 1500), + ("Stack Overflow", "stackoverflow.com", 1200), + ("GitHub", "github.com", 900), + ("Medium", "medium.com", 600) + ] + + return referrers.prefix(count).enumerated().map { index, data in + let baseValue = data.2 + let metrics = createMetrics(baseValue: baseValue, metric: metric) + return TopListData.Referrer( + name: data.0, + domain: data.1, + metrics: metrics + ) + } + } + + private static func mockLocations(metric: SiteMetric, count: Int) -> [TopListData.Location] { + let locations = [ + ("United States", "US", "🇺🇸", 5600), + ("United Kingdom", "GB", "🇬🇧", 3200), + ("Canada", "CA", "🇨🇦", 2800), + ("Germany", "DE", "🇩🇪", 2100), + ("France", "FR", "🇫🇷", 1800), + ("Japan", "JP", "🇯🇵", 1500), + ("Australia", "AU", "🇦🇺", 1200), + ("Netherlands", "NL", "🇳🇱", 900) + ] + + return locations.prefix(count).enumerated().map { index, data in + let baseValue = data.3 + let metrics = createMetrics(baseValue: baseValue, metric: metric) + return TopListData.Location( + country: data.0, + flag: data.2, + countryCode: data.1, + metrics: metrics + ) + } + } + + private static func mockAuthors(metric: SiteMetric, count: Int) -> [TopListData.Author] { + let authors = [ + ("Alex Thompson", "Editor", 1, 2400), + ("Maria Garcia", "Contributor", 2, 2100), + ("David Chen", "Editor", 3, 1800), + ("Sophie Martin", "Author", 4, 1500), + ("James Wilson", "Contributor", 5, 1200), + ("Emma Johnson", "Editor", 6, 900), + ("Michael Brown", "Author", 7, 600), + ("Sarah Davis", "Contributor", 8, 400) + ] + + return authors.prefix(count).enumerated().map { index, data in + let baseValue = data.3 + let metrics = createMetrics(baseValue: baseValue, metric: metric) + return TopListData.Author( + name: data.0, + userId: String(data.2), + role: data.1, + metrics: metrics, + avatarURL: Bundle.module.path(forResource: "author\(data.2)", ofType: "jpg").map { URL(filePath: $0) } + ) + } + } + + private static func mockExternalLinks(metric: SiteMetric, count: Int) -> [TopListData.ExternalLink] { + let links = [ + ("Apple Developer", "https://developer.apple.com", 1800), + ("Swift.org", "https://swift.org", 1500), + ("GitHub", "https://github.com", 1200), + ("Stack Overflow", "https://stackoverflow.com", 1000), + ("Ray Wenderlich", "https://raywenderlich.com", 800), + ("NSHipster", "https://nshipster.com", 600), + ("Hacking with Swift", "https://hackingwithswift.com", 450), + ("SwiftUI Lab", "https://swiftui-lab.com", 300) + ] + + return links.prefix(count).enumerated().map { index, data in + let baseValue = data.2 + let metrics = createMetrics(baseValue: baseValue, metric: metric) + return TopListData.ExternalLink( + url: data.1, + title: data.0, + metrics: metrics + ) + } + } + + private static func createMetrics(baseValue: Int, metric: SiteMetric) -> TopListData.Metrics { + // Add some variation to make it more realistic + let variation = Double.random(in: 0.8...1.2) + let value = Int(Double(baseValue) * variation) + + switch metric { + case .views: + return TopListData.Metrics(views: value) + case .visitors: + // Visitors are typically 60-80% of views + let visitorRatio = Double.random(in: 0.6...0.8) + return TopListData.Metrics(visitors: Int(Double(value) * visitorRatio)) + case .likes: + // Likes are typically 2-5% of views + let likeRatio = Double.random(in: 0.02...0.05) + return TopListData.Metrics(likes: Int(Double(value) * likeRatio)) + case .comments: + // Comments are typically 0.5-2% of views + let commentRatio = Double.random(in: 0.005...0.02) + return TopListData.Metrics(comments: Int(Double(value) * commentRatio)) + case .timeOnSite: + // Time on site not applicable for top list items + return TopListData.Metrics(views: value) + case .bounceRate: + // Bounce rate not applicable for top list items + return TopListData.Metrics(views: value) + } + } + + private static func mockPreviousItem(from item: any TopListItem, metric: SiteMetric) -> any TopListItem { + // Create previous value that's 70-130% of current value for realistic trends + let trendFactor = Double.random(in: 0.7...1.3) + let currentValue = item.metrics[metric] ?? 0 + let previousValue = Int(Double(currentValue) * trendFactor) + + var previousMetrics = item.metrics + switch metric { + case .views: + previousMetrics.views = previousValue + case .visitors: + previousMetrics.visitors = previousValue + case .likes: + previousMetrics.likes = previousValue + case .comments: + previousMetrics.comments = previousValue + case .timeOnSite, .bounceRate: + // These metrics are not applicable for top list items + previousMetrics.views = previousValue + } + + // Return the same item type with updated metrics + if let post = item as? TopListData.Post { + return TopListData.Post( + title: post.title, + postId: post.postId, + pageId: post.pageId, + type: post.type, + author: post.author, + metrics: previousMetrics + ) + } else if let referrer = item as? TopListData.Referrer { + return TopListData.Referrer( + name: referrer.name, + domain: referrer.domain, + metrics: previousMetrics + ) + } else if let location = item as? TopListData.Location { + return TopListData.Location( + country: location.country, + flag: location.flag, + countryCode: location.countryCode, + metrics: previousMetrics + ) + } else if let author = item as? TopListData.Author { + return TopListData.Author( + name: author.name, + userId: author.userId, + role: author.role, + metrics: previousMetrics, + avatarURL: author.avatarURL + ) + } else if let link = item as? TopListData.ExternalLink { + return TopListData.ExternalLink( + url: link.url, + title: link.title, + metrics: previousMetrics + ) + } + + return item + } +} diff --git a/Modules/Sources/JetpackStats/Services/Data/TopListData.swift b/Modules/Sources/JetpackStats/Services/Data/TopListData.swift new file mode 100644 index 000000000000..ff7253e3ed16 --- /dev/null +++ b/Modules/Sources/JetpackStats/Services/Data/TopListData.swift @@ -0,0 +1,78 @@ +import Foundation + +struct TopListData: Sendable { + let items: [any TopListItem] +} + +protocol TopListItem: Codable, Sendable, Identifiable { + var metrics: TopListData.Metrics { get set } + var id: String { get } +} + +extension TopListData { + struct Metrics: Codable { + var views: Int? + var visitors: Int? + var likes: Int? + var comments: Int? + var bounceRate: Int? + var timeOnSite: Int? + + subscript(metric: SiteMetric) -> Int? { + switch metric { + case .views: views + case .visitors: visitors + case .likes: likes + case .comments: comments + case .bounceRate: bounceRate + case .timeOnSite: timeOnSite + } + } + } + + struct Post: Codable, TopListItem { + let title: String + let postId: String? + let pageId: String? + let type: String? + let author: String? + var metrics: Metrics + + var id: String { postId ?? pageId ?? title } + } + + struct Referrer: Codable, TopListItem { + let name: String + let domain: String? + var metrics: Metrics + + var id: String { domain ?? name } + } + + struct Location: Codable, TopListItem { + let country: String + let flag: String? + let countryCode: String? + var metrics: Metrics + + var id: String { countryCode ?? country } + } + + struct Author: Codable, TopListItem { + let name: String + let userId: String + let role: String? + var metrics: Metrics + var avatarURL: URL? + + var id: String { userId } + } + + struct ExternalLink: Codable, TopListItem { + let url: String + let title: String? + var metrics: Metrics + + var id: String { url } + } +} diff --git a/Modules/Sources/JetpackStats/Services/Data/TopListItemType.swift b/Modules/Sources/JetpackStats/Services/Data/TopListItemType.swift new file mode 100644 index 000000000000..62813d94478e --- /dev/null +++ b/Modules/Sources/JetpackStats/Services/Data/TopListItemType.swift @@ -0,0 +1,58 @@ +import SwiftUI + +enum TopListItemType: Identifiable, CaseIterable { + case postsAndPages + case posts + case pages + case authors + case referrers + case locations + case externalLinks + + var id: TopListItemType { self } + + var localizedTitle: String { + switch self { + case .postsAndPages: Strings.SiteDataTypes.postsAndPages + case .posts: Strings.SiteDataTypes.posts + case .pages: Strings.SiteDataTypes.pages + case .authors: Strings.SiteDataTypes.authors + case .referrers: Strings.SiteDataTypes.referrers + case .locations: Strings.SiteDataTypes.locations + case .externalLinks: Strings.SiteDataTypes.externalLinks + } + } + + var systemImage: String { + switch self { + case .postsAndPages: "doc.on.doc" + case .posts: "doc.text" + case .pages: "doc" + case .referrers: "link" + case .locations: "map" + case .authors: "person.2" + case .externalLinks: "cursorarrow.click" + } + } + + var availableMetrics: [SiteMetric] { + switch self { + case .postsAndPages, .posts, .pages: [.views, .visitors, .comments, .likes] + case .referrers: SiteMetric.allCases + case .locations: SiteMetric.allCases + case .authors: [.views, .comments, .likes] + case .externalLinks: [.views, .visitors] + } + } + + func getTitle(for metric: SiteMetric) -> String { + switch metric { + case .views: Strings.TopListTitles.mostViewed + case .visitors: Strings.TopListTitles.mostVisitors + case .comments: Strings.TopListTitles.mostCommented + case .likes: Strings.TopListTitles.mostLiked + case .bounceRate: Strings.TopListTitles.highestBounceRate + case .timeOnSite: Strings.TopListTitles.longestTimeOnSite + } + } +} diff --git a/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift b/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift new file mode 100644 index 000000000000..a57c74889fc9 --- /dev/null +++ b/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift @@ -0,0 +1,451 @@ +import Foundation +import SwiftUI + +actor MockStatsService: ObservableObject, StatsServiceProtocol { + private var hourlyData: [SiteMetric: [DataPoint]] = [:] + private var dailyTopListData: [TopListItemType: [Date: [any TopListItem]]] = [:] + private let calendar: Calendar + + /// - parameter timeZone: The reporting time zone of a site. + init(timeZone: TimeZone = .current) { + var calendar = Calendar.current + calendar.timeZone = timeZone + self.calendar = calendar + } + + private func generateDataIfNeeded() async { + guard hourlyData.isEmpty else { + return + } + await generateChartMockData() + await generateTopListMockData() + } + + func getSiteStats(interval: DateInterval, granularity: DateRangeGranularity) async throws -> SiteStatsData { + await generateDataIfNeeded() + var output: [SiteMetric: [DataPoint]] = [:] + for (metric, dataPoints) in hourlyData { + // This isn't efficient by any means but it will do for the mocking purposes + let filteredDataPoints = dataPoints.filter { + interval.start <= $0.date && $0.date < interval.end + } + output[metric] = aggregateData(filteredDataPoints, granularity: granularity, range: interval, metric: metric) + } + + try? await Task.sleep(for: .milliseconds(Int.random(in: 200...600))) + + return SiteStatsData(metrics: output) + } + + func getTopListData(_ dataType: TopListItemType, range: DateInterval, granularity: DateRangeGranularity) async throws -> TopListData { + await generateDataIfNeeded() + + guard let typeData = dailyTopListData[dataType] else { + fatalError("data not configured for data type: \(dataType)") + } + + // Filter data within the date range + let filteredData = typeData.filter { date, _ in + range.start <= date && date < range.end + } + + // Aggregate all items across the date range + var aggregatedItems: [String: (any TopListItem, Int)] = [:] // Store item and aggregated metrics + + for (_, dailyItems) in filteredData { + for item in dailyItems { + let key = item.id + if let (existingItem, existingViews) = aggregatedItems[key] { + // Aggregate views + aggregatedItems[key] = (existingItem, existingViews + (item.metrics.views ?? 0)) + } else { + aggregatedItems[key] = (item, item.metrics.views ?? 0) + } + } + } + + // Convert to array with updated views and sort + let sortedItems = aggregatedItems.values + .map { (item, totalViews) -> any TopListItem in + // Create a mutable copy and update the aggregated views + var mutableItem = item + mutableItem.metrics.views = totalViews + return mutableItem + } + .sorted { ($0.metrics.views ?? 0) > ($1.metrics.views ?? 0) } + + try? await Task.sleep(for: .milliseconds(Int.random(in: 200...600))) + + return TopListData(items: Array(sortedItems.prefix(20))) + } + + func getRealtimeTopListData(_ dataType: TopListItemType) async throws -> TopListData { + // Load base items from JSON + let baseItems = loadRealtimeBaseItems(for: dataType) + + // Add dynamic variations to simulate real-time changes + let realtimeItems = baseItems.map { item -> any TopListItem in + let baseViews = item.metrics.views ?? 0 + + // Use time-based seed for consistent gradual changes + let now = Date() + let timeInMinutes = now.timeIntervalSince1970 / 60.0 + + // Get item identifier for seeding + let itemId = item.id + let itemSeed = itemId.hashValue + + // Gradual oscillation (changes slowly over time) + let slowWave = sin(timeInMinutes / 5.0 + Double(itemSeed % 100) / 10.0) * 0.1 + 1.0 + + // Small random variation (±5%) + let smallVariation = Double.random(in: 0.95...1.05) + + // Very rare small spike (1% chance, max 20% increase) + let rareSpikeChance = Double.random(in: 0.0...1.0) + let rareSpike = rareSpikeChance < 0.01 ? Double.random(in: 1.1...1.2) : 1.0 + + let realtimeViews = Int(Double(baseViews) * slowWave * smallVariation * rareSpike) + let cappedViews = min(realtimeViews, 500) // Cap at 500 + + // Apply variations to create new item with updated values + var mutableItem = item + mutableItem.metrics.views = cappedViews + + if let comments = mutableItem.metrics.comments { + mutableItem.metrics.comments = Int(Double(comments) * slowWave * smallVariation * rareSpike * 0.8) + } + if let likes = mutableItem.metrics.likes { + mutableItem.metrics.likes = Int(Double(likes) * slowWave * smallVariation * rareSpike * 0.9) + } + if let visitors = mutableItem.metrics.visitors { + mutableItem.metrics.visitors = Int(Double(visitors) * slowWave * smallVariation * rareSpike) + } + if let bounceRate = mutableItem.metrics.bounceRate { + let bounceVariation = slowWave > 1.0 ? 0.95 : 1.05 + mutableItem.metrics.bounceRate = min(100, max(0, Int(Double(bounceRate) * bounceVariation * smallVariation))) + } + if let timeOnSite = mutableItem.metrics.timeOnSite { + let timeVariation = Double.random(in: 0.85...1.15) + mutableItem.metrics.timeOnSite = Int(Double(timeOnSite) * timeVariation) + } + + return mutableItem + } + + // Sort by views and take top 10 + let sortedItems = realtimeItems + .sorted { ($0.metrics.views ?? 0) > ($1.metrics.views ?? 0) } + + let topItems = Array(sortedItems.prefix(10)) + + return TopListData( + items: topItems + ) + } + + private func loadRealtimeBaseItems(for dataType: TopListItemType) -> [any TopListItem] { + let fileName: String + switch dataType { + case .posts: + fileName = "posts" + case .pages: + fileName = "pages" + case .postsAndPages: + // Load both posts and pages + let posts = loadRealtimeBaseItems(for: .posts) + let pages = loadRealtimeBaseItems(for: .pages) + return posts + pages + case .referrers: + fileName = "referrers" + case .locations: + fileName = "locations" + case .authors: + fileName = "authors" + case .externalLinks: + // Return empty array for now as we're not implementing mocks yet + return [] + } + + // 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() + + // Decode based on data type + switch dataType { + case .posts, .pages: + let posts = try decoder.decode([TopListData.Post].self, from: data) + return posts + case .referrers: + let referrers = try decoder.decode([TopListData.Referrer].self, from: data) + return referrers + case .locations: + let locations = try decoder.decode([TopListData.Location].self, from: data) + return locations + case .authors: + let authors = try decoder.decode([TopListData.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 .postsAndPages, .externalLinks: + return [] // Already handled above + } + } catch { + print("Failed to load \(fileName).json: \(error)") + return [] + } + } + + // MARK: - Data Aggregation + + /// Aggregates raw data into data points based on granularity + private func aggregateData(_ dataPoints: [DataPoint], granularity: DateRangeGranularity, range: DateInterval, metric: SiteMetric) -> [DataPoint] { + let aggregator = StatsDataAggregator(calendar: calendar) + + // Step 1: Perform aggregation + let aggregatedData = aggregator.aggregate(dataPoints, granularity: granularity) + + // Step 2: Normalize data for metrics that need averaging + let normalizedData = aggregator.normalizeForMetric(aggregatedData, metric: metric) + + // Step 3: Generate complete data points + let dateSequence = aggregator.generateDateSequence(dateInterval: range, by: granularity.component) + + // Map dates to data points, using 0 for missing values + return dateSequence.map { date in + let aggregationDate = aggregator.makeAggegationDate(for: date, granularity: granularity) + return DataPoint(date: date, value: normalizedData[aggregationDate ?? date] ?? 0) + } + } + + /// Loads historical items from JSON files based on the data type + private func loadHistoricalItems(for dataType: TopListItemType) -> [any TopListItem] { + let fileName: String + switch dataType { + case .posts: + fileName = "historical-posts" + case .pages: + fileName = "historical-pages" + case .postsAndPages: + // Load both posts and pages + let posts = loadHistoricalItems(for: .posts) + let pages = loadHistoricalItems(for: .pages) + return posts + pages + case .referrers: + fileName = "historical-referrers" + case .locations: + fileName = "historical-locations" + case .authors: + fileName = "historical-authors" + case .externalLinks: + // Return empty array for now as we're not implementing mocks yet + return [] + } + + // Load from JSON file + guard let url = Bundle.module.url(forResource: fileName, withExtension: "json") else { + print("Failed to find \(fileName).json") + return [] + } + + do { + let data = try Data(contentsOf: url) + let decoder = JSONDecoder() + + // Decode based on data type + switch dataType { + case .posts, .pages: + let posts = try decoder.decode([TopListData.Post].self, from: data) + return posts + case .referrers: + let referrers = try decoder.decode([TopListData.Referrer].self, from: data) + return referrers + case .locations: + let locations = try decoder.decode([TopListData.Location].self, from: data) + return locations + case .authors: + let authors = try decoder.decode([TopListData.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 .postsAndPages, .externalLinks: + return [] // Already handled above + } + } catch { + print("Failed to load \(fileName).json: \(error)") + return [] + } + } + + // MARK: - Data Generation + + /// Mutates item metrics based on growth factors and variations + private func mutateItemMetrics(_ item: any TopListItem, growthFactor: Double, seasonalFactor: Double, weekendFactor: Double, randomFactor: Double) -> any TopListItem { + let combinedFactor = growthFactor * seasonalFactor * weekendFactor * randomFactor + + var item = item + if let views = item.metrics.views { + item.metrics.views = Int(Double(views) * combinedFactor) + } + if let comments = item.metrics.comments { + item.metrics.comments = Int(Double(comments) * combinedFactor * 0.8) + } + if let likes = item.metrics.likes { + item.metrics.likes = Int(Double(likes) * combinedFactor * 0.9) + } + if let visitors = item.metrics.visitors { + item.metrics.visitors = Int(Double(visitors) * combinedFactor) + } + if let bounceRate = item.metrics.bounceRate { + let bounceVariation = randomFactor > 1.0 ? 0.95 : 1.05 + item.metrics.bounceRate = min(100, max(0, Int(Double(bounceRate) * bounceVariation))) + } + if let timeOnSite = item.metrics.timeOnSite { + let timeVariation = Double.random(in: 0.85...1.15) + item.metrics.timeOnSite = Int(Double(timeOnSite) * timeVariation) + } + return item + } + + private func generateChartMockData() async { + let endDate = Date() + + // Create a date for Nov 1, 2011 + var dateComponents = DateComponents() + dateComponents.year = 2011 + dateComponents.month = 11 + dateComponents.day = 1 + + let startDate = calendar.date(from: dateComponents)! + + for dataType in SiteMetric.allCases { + var dataPoints: [DataPoint] = [] + + var currentDate = startDate + let nowDate = Date() + while currentDate <= endDate && currentDate <= nowDate { + let value = generateRealisticValue(for: dataType, at: currentDate) + let dataPoint = DataPoint(date: currentDate, value: value) + dataPoints.append(dataPoint) + currentDate = calendar.date(byAdding: .hour, value: 1, to: currentDate)! + } + + hourlyData[dataType] = dataPoints + } + } + + private func generateRealisticValue(for metric: SiteMetric, at date: Date) -> Int { + let hour = calendar.component(.hour, from: date) + let dayOfWeek = calendar.component(.weekday, from: date) + let month = calendar.component(.month, from: date) + let year = calendar.component(.year, from: date) + + // Base values and growth factors + let yearsSince2011 = year - 2011 + let growthFactor = 1.0 + (Double(yearsSince2011) * 0.15) // 15% yearly growth + + // Seasonal factor (higher in fall/winter) + let seasonalFactor = 1.0 + 0.2 * sin(2.0 * .pi * (Double(month - 3) / 12.0)) + + // Day of week factor (lower on weekends) + let weekendFactor = (dayOfWeek == 1 || dayOfWeek == 7) ? 0.7 : 1.0 + + // Hour of day factor (peak at 2pm, lowest at 3am) + let hourFactor = 0.5 + 0.5 * sin(2.0 * .pi * (Double(hour - 9) / 24.0)) + + // Random variation + let randomFactor = Double.random(in: 0.8...1.2) + + switch metric { + case .views: + let baseValue = 1000.0 + return Int(baseValue * growthFactor * seasonalFactor * weekendFactor * hourFactor * randomFactor) + + case .visitors: + let baseValue = 400.0 + return Int(baseValue * growthFactor * seasonalFactor * weekendFactor * hourFactor * randomFactor) + + case .likes: + let baseValue = 10.0 + return Int(baseValue * growthFactor * seasonalFactor * weekendFactor * randomFactor) + + case .comments: + let baseValue = 3.0 + return Int(baseValue * growthFactor * seasonalFactor * weekendFactor * randomFactor) + + case .timeOnSite: + // Time in seconds - doesn't follow same patterns + return Int(170 + Double.random(in: -40...40)) + + case .bounceRate: + // Percentage - inverse relationship with engagement + let engagementFactor = growthFactor * seasonalFactor + return Int(75 - (5 * engagementFactor) + Double.random(in: -5...5)) + } + } + + private func generateTopListMockData() async { + let endDate = Date() + + var dateComponents = DateComponents() + dateComponents.year = 2011 + dateComponents.month = 11 + dateComponents.day = 1 + + let startDate = calendar.date(from: dateComponents)! + + // Generate daily data for each type + for dataType in TopListItemType.allCases { + var typeData: [Date: [any TopListItem]] = [:] + + // Load base items from JSON files + let baseItems = loadHistoricalItems(for: dataType) + + // Skip if no items to process + if baseItems.isEmpty { + dailyTopListData[dataType] = typeData + continue + } + + var currentDate = startDate + let nowDate = Date() + while currentDate <= endDate && currentDate <= nowDate { + let dayOfWeek = calendar.component(.weekday, from: currentDate) + let month = calendar.component(.month, from: currentDate) + let year = calendar.component(.year, from: currentDate) + + // Calculate daily variations + let yearsSince2011 = year - 2011 + let growthFactor = 1.0 + (Double(yearsSince2011) * 0.12) + let seasonalFactor = 1.0 + 0.15 * sin(2.0 * .pi * (Double(month - 3) / 12.0)) + let weekendFactor = (dayOfWeek == 1 || dayOfWeek == 7) ? 0.7 : 1.0 + let randomFactor = Double.random(in: 0.8...1.2) + + // Apply mutations to each item for this day + let dailyItems = baseItems.map { item in + mutateItemMetrics(item, growthFactor: growthFactor, seasonalFactor: seasonalFactor, weekendFactor: weekendFactor, randomFactor: randomFactor) + } + + let startOfDay = calendar.startOfDay(for: currentDate) + typeData[startOfDay] = dailyItems + currentDate = calendar.date(byAdding: .day, value: 1, to: currentDate)! + } + + dailyTopListData[dataType] = typeData + } + } +} diff --git a/Modules/Sources/JetpackStats/Services/Mocks/StatsDataAggregator.swift b/Modules/Sources/JetpackStats/Services/Mocks/StatsDataAggregator.swift new file mode 100644 index 000000000000..c803bccbe953 --- /dev/null +++ b/Modules/Sources/JetpackStats/Services/Mocks/StatsDataAggregator.swift @@ -0,0 +1,114 @@ +import Foundation + +/// Represents aggregated data with sum and count +struct AggregatedDataPoint { + let sum: Int + let count: Int +} + +/// Handles data aggregation and normalization for stats. +/// +/// Example usage: +/// ```swift +/// let aggregator = StatsDataAggregator(calendar: .current) +/// +/// // Raw hourly data points across multiple days +/// let hourlyData: [Date: Int] = [ +/// Date("2025-01-15T10:15:00Z"): 120, +/// Date("2025-01-15T14:30:00Z"): 200, +/// Date("2025-01-15T20:45:00Z"): 150, +/// Date("2025-01-16T11:20:00Z"): 300, +/// Date("2025-01-16T15:10:00Z"): 180 +/// ] +/// +/// // Aggregate by day +/// let aggregated = aggregator.aggregate(hourlyData, granularity: .day) +/// // Result: [ +/// // Date("2025-01-15T00:00:00Z"): AggregatedDataPoint(sum: 470, count: 3), +/// // Date("2025-01-16T00:00:00Z"): AggregatedDataPoint(sum: 480, count: 2) +/// // ] +/// +/// // Normalize for averaged metrics (e.g., bounce rate) +/// let normalized = aggregator.normalizeForMetric(aggregated, metric: .bounceRate) +/// // Result: [ +/// // Date("2025-01-15T00:00:00Z"): 156, // 470/3 +/// // Date("2025-01-16T00:00:00Z"): 240 // 480/2 +/// // ] +/// ``` +struct StatsDataAggregator { + var calendar: Calendar = .current + + /// Aggregates data points based on the given granularity. + func aggregate(_ dataPoints: [DataPoint], granularity: DateRangeGranularity) -> [Date: AggregatedDataPoint] { + var aggregatedData: [Date: AggregatedDataPoint] = [:] + + for dataPoint in dataPoints { + if let aggregatedDate = makeAggegationDate(for: dataPoint.date, granularity: granularity) { + let existing = aggregatedData[aggregatedDate] + aggregatedData[aggregatedDate] = AggregatedDataPoint( + sum: (existing?.sum ?? 0) + dataPoint.value, + count: (existing?.count ?? 0) + 1 + ) + } + } + + return aggregatedData + } + + func makeAggegationDate(for date: Date, granularity: DateRangeGranularity) -> Date? { + let dateComponents = calendar.dateComponents(granularity.calendarComponents, from: date) + return calendar.date(from: dateComponents) + } + + /// Aggregates data based on the given granularity. + func aggregate(_ data: [Date: Int], granularity: DateRangeGranularity) -> [Date: AggregatedDataPoint] { + return aggregateByComponents(data, components: granularity.calendarComponents) + } + + /// Aggregates data by specified calendar components. + func aggregateByComponents(_ data: [Date: Int], components: Set) -> [Date: AggregatedDataPoint] { + var aggregatedData: [Date: AggregatedDataPoint] = [:] + for (date, value) in data { + let dateComponents = calendar.dateComponents(components, from: date) + if let aggregatedDate = calendar.date(from: dateComponents) { + let existing = aggregatedData[aggregatedDate] + aggregatedData[aggregatedDate] = AggregatedDataPoint( + sum: (existing?.sum ?? 0) + value, + count: (existing?.count ?? 0) + 1 + ) + } + } + return aggregatedData + } + + /// Normalizes data for metrics that need averaging (timeOnSite, bounceRate). + func normalizeForMetric(_ aggregatedData: [Date: AggregatedDataPoint], metric: SiteMetric) -> [Date: Int] { + var normalizedData: [Date: Int] = [:] + + for (date, dataPoint) in aggregatedData { + switch metric.aggregarionStrategy { + case .sum: + normalizedData[date] = dataPoint.sum + case .average: + if dataPoint.count > 0 { + normalizedData[date] = dataPoint.sum / dataPoint.count + } + } + } + + return normalizedData + } + + /// Generates sequence of dates between start and end. + func generateDateSequence(dateInterval: DateInterval, by component: Calendar.Component, value: Int = 1) -> [Date] { + var dates: [Date] = [] + var currentDate = dateInterval.start + let now = Date() + // DateInterval.end is exclusive + while currentDate < dateInterval.end && currentDate <= now { + dates.append(currentDate) + currentDate = calendar.date(byAdding: component, value: value, to: currentDate) ?? currentDate + } + return dates + } +} diff --git a/Modules/Sources/JetpackStats/Services/StatsService.swift b/Modules/Sources/JetpackStats/Services/StatsService.swift new file mode 100644 index 000000000000..677b99dbfc61 --- /dev/null +++ b/Modules/Sources/JetpackStats/Services/StatsService.swift @@ -0,0 +1,333 @@ +import Foundation +@preconcurrency import WordPressKit + +final class StatsService: StatsServiceProtocol { + private let siteID: Int + private let api: WordPressComRestApi + private let remoteService: StatsServiceRemoteV2 + + init(siteID: Int, api: WordPressComRestApi, siteTimezone: TimeZone) { + self.siteID = siteID + self.api = api + self.remoteService = StatsServiceRemoteV2( + wordPressComRestApi: api, + siteID: siteID, + siteTimezone: siteTimezone + ) + } + + /// - warning: The dates in StatsServiceRemoteV2 are represented in TimeZone.local + /// despite it accepting `siteTimezone` as a parameter. The parameter was + /// added later and is only used in a small subset of methods, which means + /// thay we have to convert the dates from the local time zone to the + /// site reporting time zone (as expected by the app). + func getSiteStats(interval: DateInterval, granularity: DateRangeGranularity) async throws -> SiteStatsData { + let period = mapGranularityToPeriod(granularity) + let endDate = interval.end + let summaryData: StatsSummaryTimeIntervalData = try await remoteService.getData(for: period, endingOn: endDate) + return mapToSiteStatsData(summaryData, interval: interval, granularity: granularity) + } + + func getTopListData(_ dataType: TopListItemType, range: DateInterval, granularity: DateRangeGranularity) async throws -> TopListData { + let period = mapGranularityToPeriod(granularity) + let endDate = range.end + + switch dataType { + case .postsAndPages: + throw StatsServiceError.notImplemented("Not implemented") + + case .pages: + throw StatsServiceError.notImplemented("Not implemented") + + case .posts: + let data: StatsTopPostsTimeIntervalData = try await remoteService.getData(for: period, endingOn: endDate) + return mapPostsToTopListData(data) + + case .referrers: + let data: StatsTopReferrersTimeIntervalData = try await remoteService.getData(for: period, endingOn: endDate) + return mapReferrersToTopListData(data) + + case .locations: + let data: StatsTopCountryTimeIntervalData = try await remoteService.getData(for: period, endingOn: endDate) + return mapCountriesToTopListData(data) + + case .authors: + let data: StatsTopAuthorsTimeIntervalData = try await remoteService.getData(for: period, endingOn: endDate) + return mapAuthorsToTopListData(data) + + case .externalLinks: + throw StatsServiceError.notImplemented("Not implemented") + } + } + + func getRealtimeTopListData(_ dataType: TopListItemType) async throws -> TopListData { + // NOTE: Realtime data requires different endpoints that are not yet available in WordPressKit + throw StatsServiceError.notImplemented("Not implemented") + } + + // MARK: - Private Helpers + + private func mapGranularityToPeriod(_ granularity: DateRangeGranularity) -> StatsPeriodUnit { + switch granularity { + case .hour: +#warning("Not implemented") + return .day + case .day: + return .day + case .month: + return .month + case .year: + return .year + } + } + + private func mapToSiteStatsData(_ summaryData: StatsSummaryTimeIntervalData, interval: DateInterval, granularity: DateRangeGranularity) -> SiteStatsData { + var metrics: [SiteMetric: [DataPoint]] = [:] + + // Map views + metrics[.views] = summaryData.summaryData.map { summary in + DataPoint(date: summary.periodStartDate, value: summary.viewsCount) + } + + // Map visitors + metrics[.visitors] = summaryData.summaryData.map { summary in + DataPoint(date: summary.periodStartDate, value: summary.visitorsCount) + } + + // Map likes + metrics[.likes] = summaryData.summaryData.map { summary in + DataPoint(date: summary.periodStartDate, value: summary.likesCount) + } + + // Map comments + metrics[.comments] = summaryData.summaryData.map { summary in + DataPoint(date: summary.periodStartDate, value: summary.commentsCount) + } + + // NOTE: Time on site and bounce rate not available in StatsSummaryData + + return SiteStatsData(metrics: metrics) + } + + private func mapPostsToTopListData(_ data: StatsTopPostsTimeIntervalData) -> TopListData { + let items = data.topPosts.map { post in + TopListData.Post( + title: post.title, + postId: String(post.postID), + pageId: nil, + type: post.kind.description, + author: nil, + metrics: TopListData.Metrics( + 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: TopListData.Metrics( + 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: TopListData.Metrics( + views: country.viewsCount + ) + ) + } + + return TopListData(items: items) + } + + private func mapAuthorsToTopListData(_ data: StatsTopAuthorsTimeIntervalData) -> TopListData { + 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: TopListData.Metrics( + views: author.viewsCount + ), + avatarURL: author.iconURL + ) + } + + 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) + } +} + +// MARK: - Custom Errors + +enum StatsServiceError: LocalizedError { + case noData + case notImplemented(String) + + var errorDescription: String? { + switch self { + case .noData: + return "No data received from the server" + case .notImplemented(let feature): + return "\(feature)" + } + } +} + +// MARK: - Mapping + +private extension WordPressKit.StatsPeriodUnit { + init(_ granularity: DateRangeGranularity) { + switch granularity { + case .hour: self = .hour + case .day: self = .day + case .month: self = .month + case .year: self = .year + } + } +} + +private extension StatsTopPost.Kind { + var description: String { + switch self { + case .post: "post" + case .page: "page" + case .homepage: "homepage" + case .unknown: "unknown" + } + } +} + +// MARK: - StatsServiceRemoteV2 Async Extensions + +private extension StatsServiceRemoteV2 { + func getData( + interval: DateInterval, + unit: StatsPeriodUnit, + limit: Int = 10 + ) 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: limit) { (data: TimeStatsType?, error: Error?) in + if let error = error { + continuation.resume(throwing: error) + } else if let data = data { + continuation.resume(returning: data) + } else { + continuation.resume(throwing: StatsServiceError.noData) + } + } + } + } + + func getInsight(limit: Int = 10) async throws -> InsightType where InsightType: Sendable { + try await withCheckedThrowingContinuation { continuation in + getInsight(limit: limit) { (insight: InsightType?, error: Error?) in + if let error = error { + continuation.resume(throwing: error) + } else if let insight = insight { + continuation.resume(returning: insight) + } else { + continuation.resume(throwing: StatsServiceError.noData) + } + } + } + } + + func getDetails(forPostID postID: Int) async throws -> StatsPostDetails { + try await withCheckedThrowingContinuation { continuation in + getDetails(forPostID: postID) { (details: StatsPostDetails?, error: Error?) in + if let error = error { + continuation.resume(throwing: error) + } else if let details = details { + continuation.resume(returning: details) + } else { + continuation.resume(throwing: StatsServiceError.noData) + } + } + } + } + + func getInsight(limit: Int = 10) async throws -> StatsLastPostInsight { + try await withCheckedThrowingContinuation { continuation in + getInsight(limit: limit) { (insight: StatsLastPostInsight?, error: Error?) in + if let error = error { + continuation.resume(throwing: error) + } else if let insight = insight { + continuation.resume(returning: insight) + } else { + continuation.resume(throwing: StatsServiceError.noData) + } + } + } + } + + func getData( + for period: StatsPeriodUnit, + endingOn: Date, + limit: Int = 10 + ) async throws -> StatsPublishedPostsTimeIntervalData { + try await withCheckedThrowingContinuation { continuation in + getData(for: period, endingOn: endingOn, limit: limit) { (data: StatsPublishedPostsTimeIntervalData?, error: Error?) in + if let error = error { + continuation.resume(throwing: error) + } else if let data = data { + continuation.resume(returning: data) + } else { + continuation.resume(throwing: StatsServiceError.noData) + } + } + } + } + + func toggleSpamState(for referrerDomain: String, currentValue: Bool) async throws { + try await withCheckedThrowingContinuation { continuation in + toggleSpamState(for: referrerDomain, currentValue: currentValue, success: { + continuation.resume() + }, failure: { error in + continuation.resume(throwing: error) + }) + } + } + + func getData( + quantity: Int, + sortField: StatsEmailsSummaryData.SortField = .opens, + sortOrder: StatsEmailsSummaryData.SortOrder = .descending + ) async throws -> StatsEmailsSummaryData { + try await withCheckedThrowingContinuation { continuation in + getData(quantity: quantity, sortField: sortField, sortOrder: sortOrder) { result in + switch result { + case .success(let data): + continuation.resume(returning: data) + case .failure(let error): + continuation.resume(throwing: error) + } + } + } + } +} diff --git a/Modules/Sources/JetpackStats/Services/StatsServiceProtocol.swift b/Modules/Sources/JetpackStats/Services/StatsServiceProtocol.swift new file mode 100644 index 000000000000..276b0c0d4e8a --- /dev/null +++ b/Modules/Sources/JetpackStats/Services/StatsServiceProtocol.swift @@ -0,0 +1,7 @@ +import Foundation + +protocol StatsServiceProtocol: AnyObject, Sendable { + func getSiteStats(interval: DateInterval, granularity: DateRangeGranularity) async throws -> SiteStatsData + func getTopListData(_ dataType: TopListItemType, range: DateInterval, granularity: DateRangeGranularity) async throws -> TopListData + func getRealtimeTopListData(_ dataType: TopListItemType) async throws -> TopListData +} diff --git a/Modules/Sources/JetpackStats/StatsContext.swift b/Modules/Sources/JetpackStats/StatsContext.swift new file mode 100644 index 000000000000..04a0996d5b68 --- /dev/null +++ b/Modules/Sources/JetpackStats/StatsContext.swift @@ -0,0 +1,59 @@ +import Foundation +import SwiftUI +import WordPressKit + +public struct StatsContext: Sendable { + /// The reporting time zone (the time zone of the site). + let timeZone: TimeZone + let calendar: Calendar + let service: any StatsServiceProtocol + let formatters: StatsFormatters + + public init(timeZone: TimeZone, siteID: Int, api: WordPressComRestApi) { + self.init(timeZone: timeZone, service: StatsService(siteID: siteID, api: api, siteTimezone: timeZone)) + } + + init(timeZone: TimeZone, service: (any StatsServiceProtocol)) { + self.timeZone = timeZone + self.calendar = { + var calendar = Calendar.current + calendar.timeZone = timeZone + return calendar + + }() + self.service = service + self.formatters = StatsFormatters(timeZone: timeZone) + } + + public static let demo = StatsContext(timeZone: .current, service: MockStatsService()) + + /// Memoized formatted pre-configured to work with the reporting time zone. + final class StatsFormatters: Sendable { + let date: StatsDateFormatter + let dateRange: StatsDateRangeFormatter + + init(timeZone: TimeZone) { + self.date = StatsDateFormatter(timeZone: timeZone) + self.dateRange = StatsDateRangeFormatter(timeZone: timeZone) + } + } +} + +extension Calendar { + static var demo: Calendar { + StatsContext.demo.calendar + } +} + +// MARK: - Environment Key + +private struct StatsContextKey: EnvironmentKey { + static let defaultValue = StatsContext.demo +} + +extension EnvironmentValues { + var context: StatsContext { + get { self[StatsContextKey.self] } + set { self[StatsContextKey.self] = newValue } + } +} diff --git a/Modules/Sources/JetpackStats/Strings.swift b/Modules/Sources/JetpackStats/Strings.swift new file mode 100644 index 000000000000..590c0a4b666c --- /dev/null +++ b/Modules/Sources/JetpackStats/Strings.swift @@ -0,0 +1,90 @@ +import Foundation + +enum Strings { + static let stats = AppLocalizedString("jetpackStats.title", value: "Stats", comment: "Stats screen title") + + enum Tabs { + static let traffic = AppLocalizedString("jetpackStats.tabs.traffic", value: "Traffic", comment: "Traffic tab") + static let realtime = AppLocalizedString("jetpackStats.tabs.realtime", value: "Realtime", comment: "Realtime tab") + static let insights = AppLocalizedString("jetpackStats.tabs.insights", value: "Insights", comment: "Insights tab") + static let subscribers = AppLocalizedString("jetpackStats.tabs.subscribers", value: "Subscribers", comment: "Subscribers tab") + } + + enum Calendar { + static let yesterday = AppLocalizedString("jetpackStats.calendar.yesterday", value: "Yesterday", comment: "Yesterday date range") + static let today = AppLocalizedString("jetpackStats.calendar.today", value: "Today", comment: "Today date range") + static let thisWeek = AppLocalizedString("jetpackStats.calendar.thisWeek", value: "This Week", comment: "This week date range") + static let thisMonth = AppLocalizedString("jetpackStats.calendar.thisMonth", value: "This Month", comment: "This month date range") + static let thisQuarter = AppLocalizedString("jetpackStats.calendar.thisQuarter", value: "This Quarter", comment: "This quarter date range") + static let thisYear = AppLocalizedString("jetpackStats.calendar.thisYear", value: "This Year", comment: "This year date range") + static let last7Days = AppLocalizedString("jetpackStats.calendar.last7Days", value: "Last 7 Days", comment: "Last 7 days date range") + static let last28Days = AppLocalizedString("jetpackStats.calendar.last28Days", value: "Last 28 Days", comment: "Last 28 days date range") + static let last30Days = AppLocalizedString("jetpackStats.calendar.last30Days", value: "Last 30 Days", comment: "Last 30 days date range") + static let last90Days = AppLocalizedString("jetpackStats.calendar.last90Days", value: "Last 90 Days", comment: "Last 90 days date range") + static let last6Months = AppLocalizedString("jetpackStats.calendar.last6Months", value: "Last 6 Months", comment: "Last 6 months date range") + static let last12Months = AppLocalizedString("jetpackStats.calendar.last12Months", value: "Last 12 Months", comment: "Last 12 months date range") + static let last5Years = AppLocalizedString("jetpackStats.calendar.last5Years", value: "Last 5 Years", comment: "Last 5 years date range") + static let last10Years = AppLocalizedString("jetpackStats.calendar.last10Years", value: "Last 10 Years", comment: "Last 10 years date range") + static let week = AppLocalizedString("jetpackStats.calendar.week", value: "Week", comment: "Week time period") + static let month = AppLocalizedString("jetpackStats.calendar.month", value: "Month", comment: "Month time period") + static let quarter = AppLocalizedString("jetpackStats.calendar.quarter", value: "Quarter", comment: "Quarter time period") + static let year = AppLocalizedString("jetpackStats.calendar.year", value: "Year", comment: "Year time period") + } + + enum SiteMetrics { + static let views = AppLocalizedString("jetpackStats.siteMetrics.views", value: "Views", comment: "Site views metric") + static let visitors = AppLocalizedString("jetpackStats.siteMetrics.visitors", value: "Visitors", comment: "Site visitors metric") + static let visitorsNow = AppLocalizedString("jetpackStats.siteMetrics.visitorsNow", value: "Visitors Now", comment: "Current active visitors metric") + static let likes = AppLocalizedString("jetpackStats.siteMetrics.likes", value: "Likes", comment: "Site likes metric") + static let comments = AppLocalizedString("jetpackStats.siteMetrics.comments", value: "Comments", comment: "Site comments metric") + static let timeOnSite = AppLocalizedString("jetpackStats.siteMetrics.timeOnSite", value: "Time on Site", comment: "Time on site metric") + static let bounceRate = AppLocalizedString("jetpackStats.siteMetrics.bounceRate", value: "Bounce Rate", comment: "Bounce rate metric") + } + + enum SiteDataTypes { + static let postsAndPages = AppLocalizedString("jetpackStats.siteDataTypes.postsAndPages", value: "Posts & Pages", comment: "Posts and pages data type") + static let posts = AppLocalizedString("jetpackStats.siteDataTypes.posts", value: "Posts", comment: "Posts data type") + static let pages = AppLocalizedString("jetpackStats.siteDataTypes.pages", value: "Pages", comment: "Pages data type") + static let authors = AppLocalizedString("jetpackStats.siteDataTypes.authors", value: "Authors", comment: "Authors data type") + static let referrers = AppLocalizedString("jetpackStats.siteDataTypes.referrers", value: "Referrers", comment: "Referrers data type") + static let locations = AppLocalizedString("jetpackStats.siteDataTypes.locations", value: "Locations", comment: "Locations data type") + static let externalLinks = AppLocalizedString("jetpackStats.siteDataTypes.externalLinks", value: "External Links", comment: "External links data type") + } + + 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") + static let share = AppLocalizedString("jetpackStats.button.share", value: "Share", comment: "Share chart menu item") + static let showAll = AppLocalizedString("jetpackStats.button.showAll", value: "Show All", comment: "Button title") + } + + enum DatePicker { + static let customRange = AppLocalizedString("jetpackStats.datePicker.customRange", value: "Custom Range", comment: "Title for custom date range picker") + static let customRangeMenu = AppLocalizedString("jetpackStats.datePicker.customRangeMenu", value: "Custom Range…", comment: "Menu item for custom date range picker") + static let morePeriods = AppLocalizedString("jetpackStats.datePicker.morePeriods", value: "More Periods…", comment: "Menu item for more date period options") + static let from = AppLocalizedString("jetpackStats.datePicker.from", value: "From", comment: "From date label") + static let to = AppLocalizedString("jetpackStats.datePicker.to", value: "To", comment: "To date label") + static let quickPeriodsForStartDate = AppLocalizedString("jetpackStats.datePicker.quickPeriodsForStartDate", value: "Quick periods for start date", comment: "Label for quick period selection") + static let siteTimeZone = AppLocalizedString("jetpackStats.datePicker.siteTimeZone", value: "Site Time Zone", comment: "Site time zone header") + static let siteTimeZoneDescription = AppLocalizedString("jetpackStats.datePicker.siteTimeZoneDescription", value: "Stats are reported and shown in your site's time zone. If a visitor comes to your site on Tuesday in their time zone, but it's Monday in your site time zone, the visit is recorded as Monday.", comment: "Explanation of how stats are reported in site time zone") + static let compareWith = AppLocalizedString("jetpackStats.datePicker.compareWith", value: "Compare With…", comment: "Title for comparison menu") + static let precedingPeriod = AppLocalizedString("jetpackStats.datePicker.precedingPeriod", value: "Preceding Period", comment: "Compare with preceding period option") + static let samePeriodLastYear = AppLocalizedString("jetpackStats.datePicker.lastYear", value: "Last Year", comment: "Compare with same period last year option") + } + + enum Chart { + static let showData = AppLocalizedString("jetpackStats.chart.showData", value: "Show Data", comment: "Show chart data menu item") + static let lineChart = AppLocalizedString("jetpackStats.chart.lineChart", value: "Lines", comment: "Line chart type") + static let barChart = AppLocalizedString("jetpackStats.chart.barChart", value: "Bars", comment: "Bar chart type") + static let incompleteData = AppLocalizedString("jetpackStats.chart.incompleteData", value: "Might show incomplete data", comment: "Shown when current period data might be incomplete") + } + + enum TopListTitles { + static let mostViewed = AppLocalizedString("jetpackStats.topList.mostViewed", value: "Most Viewed", comment: "Title for most viewed items") + static let mostVisitors = AppLocalizedString("jetpackStats.topList.mostVisitors", value: "Most Visitors", comment: "Title for items with most visitors") + static let mostCommented = AppLocalizedString("jetpackStats.topList.mostCommented", value: "Most Commented", comment: "Title for most commented items") + static let mostLiked = AppLocalizedString("jetpackStats.topList.mostLiked", value: "Most Liked", comment: "Title for most liked items") + static let highestBounceRate = AppLocalizedString("jetpackStats.topList.highestBounceRate", value: "Highest Bounce Rate", comment: "Title for items with highest bounce rate") + static let longestTimeOnSite = AppLocalizedString("jetpackStats.topList.longestTimeOnSite", value: "Longest Time on Site", comment: "Title for items with longest time on site") + } +} diff --git a/Modules/Sources/JetpackStats/Utilities/AppLocalizedString.swift b/Modules/Sources/JetpackStats/Utilities/AppLocalizedString.swift new file mode 100644 index 000000000000..42d411cab350 --- /dev/null +++ b/Modules/Sources/JetpackStats/Utilities/AppLocalizedString.swift @@ -0,0 +1,28 @@ +import Foundation + +public func AppLocalizedString(_ key: String, tableName: String? = nil, value: String? = nil, comment: String) -> String { + Bundle.app.localizedString(forKey: key, value: value, table: nil) +} + +private extension Bundle { + /// Returns the `Bundle` for the host `.app`. + /// + /// - If this is called from code already located in the main app's bundle or from a Pod/Framework, + /// this will return the same as `Bundle.main`, aka the bundle of the app itself. + /// - If this is called from an App Extension (Widget, ShareExtension, etc), this will return the bundle of the + /// main app hosting said App Extension (while `Bundle.main` would return the App Extension itself) + /// + /// This is particularly useful to reference a resource or string bundled inside the app from an App Extension / Widget. + /// + /// - Note: + /// In the context of Unit Tests this will return the Test Harness (aka Test Host) app, since that is the app running said tests. + /// + static let app: Bundle = { + var url = Bundle.main.bundleURL + while url.pathExtension != "app" && url.lastPathComponent != "/" { + url.deleteLastPathComponent() + } + guard let appBundle = Bundle(url: url) else { fatalError("Unable to find the parent app bundle") } + return appBundle + }() +} diff --git a/Modules/Sources/JetpackStats/Utilities/DateRangeGranularity.swift b/Modules/Sources/JetpackStats/Utilities/DateRangeGranularity.swift new file mode 100644 index 000000000000..a71591e76f24 --- /dev/null +++ b/Modules/Sources/JetpackStats/Utilities/DateRangeGranularity.swift @@ -0,0 +1,58 @@ +import Foundation + +enum DateRangeGranularity { + case hour + case day + case month + case year +} + +extension DateInterval { + /// Automatically determine the appropriate period for chart display based + /// on date range. This aims to show between 7 and 30 data points for optimal + /// visualization on both bar charts and line charts where you can use drag + /// gesture to see information about individual periods. + var preferredGranularity: DateRangeGranularity { + // Calculate total days for more accurate granularity selection + let totalDays = Int(ceil(duration / 86400)) // 86400 seconds in a day + + // For ranges <= 1 day: show hourly data (up to 24 points) + if totalDays <= 1 { + return .hour + } + // For ranges 2-90 days: show daily data (2-90 points) + else if totalDays <= 90 { + return .day + } + // For ranges 91-730 days: show monthly data (~3-24 points) + else if totalDays <= 730 { // 730 days = ~2 years + return .month + } + // For ranges > 2 years: show yearly data + else { + return .year + } + } +} + +extension DateRangeGranularity { + /// Components needed to aggregate data at this granularity + var calendarComponents: Set { + switch self { + case .hour: [.year, .month, .day, .hour] + case .day: [.year, .month, .day] + case .month: [.year, .month] + case .year: [.year] + } + } + + /// Component to increment when generating date sequences + var component: Calendar.Component { + switch self { + case .hour: .hour + case .day: .day + case .month: .month + case .year: .year + } + } +} diff --git a/Modules/Sources/JetpackStats/Utilities/Formatters/StatsDateFormatter.swift b/Modules/Sources/JetpackStats/Utilities/Formatters/StatsDateFormatter.swift new file mode 100644 index 000000000000..8e1cf7cf4e59 --- /dev/null +++ b/Modules/Sources/JetpackStats/Utilities/Formatters/StatsDateFormatter.swift @@ -0,0 +1,36 @@ +import Foundation + +struct StatsDateFormatter { + var locale: Locale + var timeZone: TimeZone + + init(locale: Locale = .current, timeZone: TimeZone = .current) { + self.locale = locale + self.timeZone = timeZone + } + + func formatDate(_ date: Date, granularity: DateRangeGranularity) -> String { + let formatter = DateFormatter() + formatter.locale = locale + formatter.timeZone = timeZone + + switch granularity { + case .hour: + formatter.dateFormat = "h a" // 3 AM + case .day: + formatter.dateFormat = "MMM d" + case .month: + formatter.dateFormat = "MMM" + case .year: + formatter.dateFormat = "yyyy" + } + return formatter.string(from: date) + } + + var formattedTimeOffset: String { + let formatter = DateFormatter() + formatter.timeZone = timeZone + formatter.dateFormat = "ZZZZ" // "GMT-05:00" + return formatter.string(from: Date()) + } +} diff --git a/Modules/Sources/JetpackStats/Utilities/Formatters/StatsDateRangeFormatter.swift b/Modules/Sources/JetpackStats/Utilities/Formatters/StatsDateRangeFormatter.swift new file mode 100644 index 000000000000..c8e7705b6099 --- /dev/null +++ b/Modules/Sources/JetpackStats/Utilities/Formatters/StatsDateRangeFormatter.swift @@ -0,0 +1,156 @@ +import Foundation + +/// Formats date intervals for display in stats UI, with smart year display. +/// +/// Years are shown only when dates are not in the current year. +/// +/// ## Examples +/// +/// ```swift +/// // Current year: no year shown +/// "Mar 15" // Single day +/// "Jan 1 – 5" // Same month +/// "Jan 31 – Feb 2" // Cross month +/// +/// // Previous year: year shown +/// "Mar 15, 2024" // Single day +/// "Jan 1 – 5, 2024" // Same month +/// "Jan 31 – Feb 2, 2024" // Cross month +/// +/// // Cross-year ranges: both years shown +/// "Dec 31, 2024 – Jan 2, 2025" +/// +/// // Special cases +/// "Jan 2025" // Entire month +/// "Jan – May 2025" // Multiple full months +/// "2025" // Entire year +/// "2020 – 2023" // Multiple full years +/// ``` +struct StatsDateRangeFormatter { + private let locale: Locale + private let timeZone: TimeZone + private let dateFormatter = DateFormatter() + private let dateIntervalFormatter = DateIntervalFormatter() + + init(locale: Locale = .current, timeZone: TimeZone = .current) { + self.locale = locale + self.timeZone = timeZone + + dateFormatter.locale = locale + dateFormatter.timeZone = timeZone + + dateIntervalFormatter.locale = locale + dateIntervalFormatter.timeZone = timeZone + dateIntervalFormatter.dateStyle = .medium + dateIntervalFormatter.timeStyle = .none + } + + func string(from interval: DateInterval, now: Date = Date()) -> String { + var calendar = Calendar.current + calendar.timeZone = timeZone + + let startDate = interval.start + let endDate = interval.end + let currentYear = calendar.component(.year, from: now) + + // Check if it's an entire year + if let yearInterval = calendar.dateInterval(of: .year, for: startDate), + calendar.isDate(yearInterval.start, inSameDayAs: startDate) && + calendar.isDate(yearInterval.end, inSameDayAs: endDate) { + dateFormatter.dateFormat = "yyyy" + return dateFormatter.string(from: startDate) + } + + // Check if it's an entire month + if let monthInterval = calendar.dateInterval(of: .month, for: startDate), + calendar.isDate(monthInterval.start, inSameDayAs: startDate) && + calendar.isDate(monthInterval.end, inSameDayAs: endDate) { + dateFormatter.dateFormat = "MMM yyyy" + return dateFormatter.string(from: startDate) + } + + // Check if it's multiple full years + if isMultipleFullYears(interval: interval, calendar: calendar) { + let displayedEndDate = calendar.date(byAdding: .second, value: -1, to: endDate) ?? endDate + dateFormatter.dateFormat = "yyyy" + let startYear = dateFormatter.string(from: startDate) + let endYear = dateFormatter.string(from: displayedEndDate) + return "\(startYear) – \(endYear)" + } + + // Check if it's multiple full months + if isMultipleFullMonths(interval: interval, calendar: calendar) { + let startYear = calendar.component(.year, from: startDate) + let endYear = calendar.component(.year, from: endDate) + let displayedEndDate = calendar.date(byAdding: .second, value: -1, to: endDate) ?? endDate + + if startYear == endYear { + // Same year: "Jan – May 2025" + dateFormatter.dateFormat = "MMM" + let startMonth = dateFormatter.string(from: startDate) + let endMonth = dateFormatter.string(from: displayedEndDate) + dateFormatter.dateFormat = "yyyy" + let year = dateFormatter.string(from: startDate) + return "\(startMonth) – \(endMonth) \(year)" + } else { + // Different years: "Dec 2024 – Feb 2025" + dateFormatter.dateFormat = "MMM yyyy" + let start = dateFormatter.string(from: startDate) + let end = dateFormatter.string(from: displayedEndDate) + return "\(start) – \(end)" + } + } + + // Default formatting for other ranges + let displayedEndDate = calendar.date(byAdding: .second, value: -1, to: endDate) ?? endDate + + if calendar.component(.year, from: startDate) == currentYear && calendar.component(.year, from: displayedEndDate) == currentYear { + dateIntervalFormatter.dateTemplate = "MMM d" + } + + return dateIntervalFormatter.string(from: startDate, to: displayedEndDate) + } + + private func isMultipleFullYears(interval: DateInterval, calendar: Calendar) -> Bool { + let startDate = interval.start + let endDate = interval.end + + // Check if start date is January 1st + guard calendar.component(.month, from: startDate) == 1 else { return false } + guard calendar.component(.day, from: startDate) == 1 else { return false } + + // Check if end date is January 1st (open interval) + guard calendar.component(.month, from: endDate) == 1 else { return false } + guard calendar.component(.day, from: endDate) == 1 else { return false } + + // Check if it spans more than one year + let startYear = calendar.component(.year, from: startDate) + let endYear = calendar.component(.year, from: endDate) + + return endYear > startYear + 1 + } + + private func isMultipleFullMonths(interval: DateInterval, calendar: Calendar) -> Bool { + let startDate = interval.start + let endDate = interval.end + + // Check if start date is the first day of a month + guard calendar.component(.day, from: startDate) == 1 else { return false } + + // Check if end date is the first day of a month (open interval) + guard calendar.component(.day, from: endDate) == 1 else { return false } + + // Check if it spans more than one month + let startMonth = calendar.component(.month, from: startDate) + let startYear = calendar.component(.year, from: startDate) + let endMonth = calendar.component(.month, from: endDate) + let endYear = calendar.component(.year, from: endDate) + + if startYear == endYear { + return endMonth > startMonth + 1 + } else { + // Cross-year: always multiple months + return true + } + } +} diff --git a/Modules/Sources/JetpackStats/Utilities/Formatters/StatsValueFormatter.swift b/Modules/Sources/JetpackStats/Utilities/Formatters/StatsValueFormatter.swift new file mode 100644 index 000000000000..d7112b59f635 --- /dev/null +++ b/Modules/Sources/JetpackStats/Utilities/Formatters/StatsValueFormatter.swift @@ -0,0 +1,86 @@ +import Foundation + +/// Formats site metric values for display based on the metric type and context. +/// +/// Example usage: +/// ```swift +/// let formatter = StatsValueFormatter(metric: .timeOnSite) +/// formatter.format(value: 90) // "1m 30s" +/// formatter.format(value: 90, context: .compact) // "1m" +/// +/// let viewsFormatter = StatsValueFormatter(metric: .views) +/// viewsFormatter.format(value: 15789) // "15,789" +/// viewsFormatter.format(value: 15789, context: .compact) // "16K" +/// ``` +struct StatsValueFormatter { + enum Context { + case regular + case compact + } + + let metric: SiteMetric + + init(metric: SiteMetric) { + self.metric = metric + } + + func format(value: Int, context: Context = .regular) -> String { + switch metric { + case .timeOnSite: + let minutes = value / 60 + let seconds = value % 60 + if minutes > 0 { + switch context { + case .regular: + return "\(minutes)m \(seconds)s" + case .compact: + return "\(minutes)m" + } + } else { + return "\(seconds)s" + } + case .bounceRate: + return "\(value)%" + default: + return Self.formatNumber(value, onlyLarge: context == .regular) + } + } + + /// Formats a number with appropriate abbreviations for large values. + /// + /// - Parameters: + /// - value: The number to format + /// - onlyLarge: If true, only abbreviates numbers >= 10,000 + /// - Returns: A formatted string (e.g., "1.2K", "1.5M") + /// + /// Example: + /// ```swift + /// StatsValueFormatter.formatNumber(1234) // "1.2K" + /// StatsValueFormatter.formatNumber(1234, onlyLarge: true) // "1,234" + /// StatsValueFormatter.formatNumber(15789) // "16K" + /// ``` + static func formatNumber(_ value: Int, onlyLarge: Bool = false) -> String { + if onlyLarge && abs(value) < 10_000 { + return value.formatted(.number) + } + return value.formatted(.number.notation(.compactName)) + } + + /// Calculates the percentage change between two values. + /// + /// - Parameters: + /// - current: The current value + /// - previous: The previous value to compare against + /// - Returns: The percentage change as a decimal (0.5 = 50% increase, -0.5 = 50% decrease) + /// + /// Example: + /// ```swift + /// let formatter = StatsValueFormatter(metric: .views) + /// formatter.percentageChange(current: 150, previous: 100) // 0.5 (50% increase) + /// formatter.percentageChange(current: 50, previous: 100) // -0.5 (50% decrease) + /// ``` + func percentageChange(current: Int, previous: Int) -> Double { + guard previous > 0 else { return 0 } + return Double(current - previous) / Double(previous) + } +} diff --git a/Modules/Sources/JetpackStats/Utilities/Modifiers/CardModifier.swift b/Modules/Sources/JetpackStats/Utilities/Modifiers/CardModifier.swift new file mode 100644 index 000000000000..869839ab2446 --- /dev/null +++ b/Modules/Sources/JetpackStats/Utilities/Modifiers/CardModifier.swift @@ -0,0 +1,20 @@ +import SwiftUI + +struct CardModifier: ViewModifier { + func body(content: Content) -> some View { + content + .background(Color(UIColor(light: .systemBackground, dark: .secondarySystemBackground))) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(Color(.opaqueSeparator), lineWidth: 0.5) + ) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .padding(.horizontal, Constants.step1) + } +} + +extension View { + func cardStyle() -> some View { + modifier(CardModifier()) + } +} diff --git a/Modules/Sources/JetpackStats/Utilities/Modifiers/ChartSelectionModifier.swift b/Modules/Sources/JetpackStats/Utilities/Modifiers/ChartSelectionModifier.swift new file mode 100644 index 000000000000..823405c3594d --- /dev/null +++ b/Modules/Sources/JetpackStats/Utilities/Modifiers/ChartSelectionModifier.swift @@ -0,0 +1,13 @@ +import SwiftUI + +struct ChartSelectionModifier: ViewModifier { + @Binding var selection: Date? + + func body(content: Content) -> some View { + if #available(iOS 17.0, *) { + content.chartXSelection(value: $selection) + } else { + content + } + } +} diff --git a/Modules/Sources/JetpackStats/Utilities/Modifiers/ScrollOffsetModifier.swift b/Modules/Sources/JetpackStats/Utilities/Modifiers/ScrollOffsetModifier.swift new file mode 100644 index 000000000000..eb77bce4b007 --- /dev/null +++ b/Modules/Sources/JetpackStats/Utilities/Modifiers/ScrollOffsetModifier.swift @@ -0,0 +1,26 @@ +import SwiftUI + +@available(iOS 18.0, *) +struct ScrollOffsetModifier: ViewModifier { + @Binding var isScrolled: Bool + + func body(content: Content) -> some View { + content + .onScrollGeometryChange(for: Bool.self) { geometry in + return (geometry.contentOffset.y - 20) > -geometry.contentInsets.top + } action: { _, newValue in + isScrolled = newValue + } + } +} + +extension View { + @ViewBuilder + func trackScrollOffset(isScrolling: Binding) -> some View { + if #available(iOS 18.0, *) { + modifier(ScrollOffsetModifier(isScrolled: isScrolling)) + } else { + self + } + } +} diff --git a/Modules/Sources/JetpackStats/Utilities/SelectedDataPoints.swift b/Modules/Sources/JetpackStats/Utilities/SelectedDataPoints.swift new file mode 100644 index 000000000000..0b0405d18721 --- /dev/null +++ b/Modules/Sources/JetpackStats/Utilities/SelectedDataPoints.swift @@ -0,0 +1,47 @@ +import Foundation + +struct SelectedDataPoints { + let current: DataPoint? + let previous: DataPoint? + + // Static method to compute selected data points from a date + static func compute( + for date: Date?, + currentSeries: [DataPoint], + previousSeries: [DataPoint] + ) -> SelectedDataPoints? { + guard let date else { return nil } + + // Since mappedPreviousData has the same dates as currentData, + // we only need to find the closest date in the current series + guard !currentSeries.isEmpty else { return nil } + + // Find the closest data point in the current series + guard let closestPoint = findClosestDataPoint(to: date, in: currentSeries + previousSeries) else { + return nil + } + + // Find the closest date value + let closestDate = closestPoint.date + + // Find points with this exact date in both series + let currentPoint = currentSeries.first { $0.date == closestDate } + let previousPoint = previousSeries.first { $0.date == closestDate } + + return SelectedDataPoints(current: currentPoint, previous: previousPoint) + } + + static func compute(for date: Date?, data: ChartData) -> SelectedDataPoints? { + compute(for: date, currentSeries: data.currentData, previousSeries: data.mappedPreviousData) + } + + // Helper method to find the closest data point to a given date + private static func findClosestDataPoint(to date: Date, in points: [DataPoint]) -> DataPoint? { + guard !points.isEmpty else { return nil } + + // Find the point with minimum time difference + return points.min { point1, point2 in + abs(point1.date.timeIntervalSince(date)) < abs(point2.date.timeIntervalSince(date)) + } + } +} diff --git a/Modules/Sources/JetpackStats/Utilities/StatsDateRange.swift b/Modules/Sources/JetpackStats/Utilities/StatsDateRange.swift new file mode 100644 index 000000000000..0536ac2901ad --- /dev/null +++ b/Modules/Sources/JetpackStats/Utilities/StatsDateRange.swift @@ -0,0 +1,118 @@ +import Foundation + +struct StatsDateRange: Equatable, Sendable { + /// The primary date range for statistics. + var dateInterval: DateInterval + + /// The navigation component (.day, .month, .year). If it's provided, it means + /// the date interval was created with a preset to represet the respective + /// date period. If nil, uses duration-based navigation. + var component: Calendar.Component + + /// The comparison period type. Defaults to `.precedingPeriod` if nil. + var comparison: DateRangeComparisonPeriod + + /// The calculated comparison date range. + var effectiveComparisonInterval: DateInterval + + /// The calendar used for date calculations. + let calendar: Calendar + + init( + interval: DateInterval, + component: Calendar.Component, + comparison: DateRangeComparisonPeriod = .precedingPeriod, + calendar: Calendar = .current + ) { + self.dateInterval = interval + self.comparison = comparison + self.component = component + self.calendar = calendar + self.effectiveComparisonInterval = interval + self.refreshEffectiveComparisonPeriodInterval() + } + + mutating func update(preset: DateIntervalPreset) { + dateInterval = calendar.makeDateInterval(for: preset) + component = preset.component + refreshEffectiveComparisonPeriodInterval() + } + + mutating func update(comparisonPeriod: DateRangeComparisonPeriod) { + self.comparison = comparisonPeriod + refreshEffectiveComparisonPeriodInterval() + } + + private mutating func refreshEffectiveComparisonPeriodInterval() { + effectiveComparisonInterval = calendar.comparisonRange(for: dateInterval, period: comparison, component: component) + } + + // MARK: - Navigation + + /// Navigates to the specified direction (previous or next period). + func navigate(_ direction: Calendar.NavigationDirection) -> StatsDateRange { + // Use the component if available, otherwise determine it from the interval + let newInterval = calendar.navigate(dateInterval, direction: direction, component: component) + return StatsDateRange(interval: newInterval, component: component, comparison: comparison, calendar: calendar) + } + + /// Returns true if can navigate in the specified direction. + func canNavigate(in direction: Calendar.NavigationDirection) -> Bool { + calendar.canNavigate(dateInterval, direction: direction) + } + + /// Returns true if the specified comparison period is enabled. + func isComparisonPeriodEnabled(_ period: DateRangeComparisonPeriod) -> Bool { + switch period { + case .precedingPeriod: + // Preceding period is always available + return true + case .samePeriodLastYear: + // Same period last year requires the date range to be less than a year + let components = calendar.dateComponents([.year], from: dateInterval.start, to: dateInterval.end) + return (components.year ?? 0) < 1 + } + } + + /// Generates a list of available adjacent periods in the specified direction. + /// - Parameters: + /// - direction: The navigation direction (previous or next) + /// - maxCount: Maximum number of periods to generate (default: 10) + /// - Returns: Array of AdjacentPeriod structs + func availableAdjacentPeriods(in direction: Calendar.NavigationDirection, maxCount: Int = 10) -> [AdjacentPeriod] { + var periods: [AdjacentPeriod] = [] + var currentRange = self + let formatter = StatsDateRangeFormatter(timeZone: calendar.timeZone) + for _ in 0.. StatsDateRange { + StatsDateRange( + interval: makeDateInterval(for: preset), + component: preset.component, + comparison: comparison, + calendar: self + ) + } +} diff --git a/Modules/Sources/JetpackStats/Utilities/TrendViewModel.swift b/Modules/Sources/JetpackStats/Utilities/TrendViewModel.swift new file mode 100644 index 000000000000..39e483efa116 --- /dev/null +++ b/Modules/Sources/JetpackStats/Utilities/TrendViewModel.swift @@ -0,0 +1,140 @@ +import Foundation +import SwiftUI + +/// Represents a change from the current to the previous value and determines +/// a trend: is it a positive change, what's the percentage, etc. +struct TrendViewModel: Hashable { + let currentValue: Int + let previousValue: Int + let metric: SiteMetric + var context: StatsValueFormatter.Context = .compact + + /// The sign prefix for the change value. + var sign: String { + currentValue >= previousValue ? "+" : "-" + } + + var iconSign: String { + currentValue >= previousValue ? "↗" : "↘" + } + + /// SF Symbol name representing the trend direction + var systemImage: String { + guard currentValue != previousValue else { + return "arrow.up.and.down" + } + return currentValue >= previousValue ? "arrow.up.forward" : "arrow.down.forward" + } + + /// The perceived quality of the change based on metric type. + var sentiment: TrendSentiment { + if currentValue == previousValue { + return .neutral + } + let sentiment: TrendSentiment = currentValue >= previousValue ? .positive : .negative + return metric.isHigherValueBetter ? sentiment : sentiment.reversed() + } + + /// The percentage change between periods (nil if the previous value was 0). + /// - Example: 0.5 for 50% increase, 0.25 for 25% decrease + var percentage: Double? { + guard previousValue != 0 else { + return nil + } + return Double(abs(currentValue - previousValue)) / Double(abs(previousValue)) + } + + // MARK: Formatting + + /// A completed formatted trend with the absolute change and the percentage change. + var formattedTrend: String { + "\(formattedChange) (\(formattedPercentage))" + } + + /// A completed formatted trend with the absolute change and the percentage change. + var formattedTrendShort: String { + "\(iconSign) \(formattedPercentage) \(formattedChange)" + } + + /// A completed formatted trend with the absolute change and the percentage change. + var formattedTrendShort2: String { + "\(formattedChange)  \(iconSign) \(formattedPercentage)" + } + + /// Formatted string showing the absolute change with sign. + /// - Example: "+1.2K" for 1,200 increase, "-500" for 500 decrease. + var formattedChange: String { + "\(sign)\(formattedChangeNoSign)" + } + + var formattedChangeNoSign: String { + formattedValue(abs(currentValue - previousValue)) + } + + var formattedCurrentValue: String { + formattedValue(currentValue) + } + + var formattedPreviousValue: String { + formattedValue(previousValue) + } + + private func formattedValue(_ value: Int) -> String { + StatsValueFormatter(metric: metric) + .format(value: value, context: context) + } + + /// Formatted percentage string (shows "∞" for infinite change) + /// - Example: "25%", "150.5%", or "∞" when previousValue was 0. + var formattedPercentage: String { + guard let percentage else { return "∞" } + return percentage.formatted(.percent.precision(.fractionLength(0...1))) + } +} + +extension TrendViewModel { + static func make(_ chartData: ChartData, context: StatsValueFormatter.Context = .compact) -> TrendViewModel { + TrendViewModel( + currentValue: chartData.currentTotal, + previousValue: chartData.previousTotal, + metric: chartData.metric, + context: context + ) + } +} + +/// The change can be percieved as either positive or negative depending on +/// the data type. For example, growth in "Views" is positive but grown in +/// "Bounce Rate" is negative. +enum TrendSentiment { + /// No change. + case neutral + /// The change in the value is positive (e.g. "more views", or "lower bounce rate"). + case positive + case negative + + var foregroundColor: Color { + switch self { + case .neutral: Color.secondary + case .positive: Constants.Colors.positiveChangeForeground + case .negative: Constants.Colors.negativeChangeForeground + } + } + + var backgroundColor: Color { + switch self { + case .neutral: Color(UIColor(light: .secondarySystemBackground, dark: .tertiarySystemBackground)) + case .positive: Constants.Colors.positiveChangeBackground + case .negative: Constants.Colors.negativeChangeBackground + } + } + + /// Returns the opposite sentiment (for metrics where lower is better) + func reversed() -> TrendSentiment { + switch self { + case .neutral: .neutral + case .positive: .negative + case .negative: .positive + } + } +} diff --git a/Modules/Sources/JetpackStats/Views/AvatarView.swift b/Modules/Sources/JetpackStats/Views/AvatarView.swift new file mode 100644 index 000000000000..3e9f52ffcdc2 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/AvatarView.swift @@ -0,0 +1,48 @@ +import SwiftUI +import WordPressUI +import AsyncImageKit + +struct AvatarView: View { + let name: String + let imageURL: URL? + let size: CGFloat + + init(name: String, imageURL: URL? = nil, size: CGFloat = 36) { + self.name = name + self.imageURL = imageURL + self.size = size + } + + var body: some View { + if let imageURL { + CachedAsyncImage(url: imageURL) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { + placeholderView + } + .frame(width: size, height: size) + .clipShape(Circle()) + } else { + placeholderView + } + } + + private var placeholderView: some View { + Circle() + .fill(Color(.systemBackground)) + .frame(width: size, height: size) + .overlay( + Text(initials) + .font(.system(size: size * 0.4, weight: .medium)) + .foregroundColor(Color.secondary) + ) + } + + private var initials: String { + let words = name.split(separator: " ") + let initials = words.prefix(2).compactMap { $0.first?.uppercased() }.joined() + return initials.isEmpty ? "?" : initials + } +} diff --git a/Modules/Sources/JetpackStats/Views/BadgeTrendIndicator.swift b/Modules/Sources/JetpackStats/Views/BadgeTrendIndicator.swift new file mode 100644 index 000000000000..86d50906c6f5 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/BadgeTrendIndicator.swift @@ -0,0 +1,46 @@ +import SwiftUI + +struct BadgeTrendIndicator: View { + let trend: TrendViewModel + + init(trend: TrendViewModel) { + self.trend = trend + } + + var body: some View { + HStack(spacing: 4) { + Image(systemName: trend.systemImage) + .font(.caption2.weight(.semibold)) + .scaleEffect(x: 0.9, y: 0.9) + Text(trend.formattedPercentage) + .font(.footnote.weight(.semibold)) + } + .foregroundColor(trend.sentiment.foregroundColor) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(trend.sentiment.backgroundColor) + .cornerRadius(6) + .animation(.spring, value: trend.percentage) + } +} + +#Preview("Change Indicators") { + VStack(spacing: 20) { + Text("Examples").font(.headline) + // 15% increase in views - positive sentiment + BadgeTrendIndicator(trend: TrendViewModel(currentValue: 115, previousValue: 100, metric: .views)) + // 15% decrease in views - negative sentiment + BadgeTrendIndicator(trend: TrendViewModel(currentValue: 85, previousValue: 100, metric: .views)) + // 0.1% increase in views - negative sentiment + BadgeTrendIndicator(trend: TrendViewModel(currentValue: 1001, previousValue: 1000, metric: .views)) + + Text("Edge Cases").font(.headline).padding(.top) + // No change + BadgeTrendIndicator(trend: TrendViewModel(currentValue: 100, previousValue: 100, metric: .views)) + // Division by zero (from 0 to 100) + BadgeTrendIndicator(trend: TrendViewModel(currentValue: 100, previousValue: 0, metric: .views)) + // Large change + BadgeTrendIndicator(trend: TrendViewModel(currentValue: 400, previousValue: 100, metric: .views)) + } + .padding() +} diff --git a/Modules/Sources/JetpackStats/Views/ChartAxisDateLabel.swift b/Modules/Sources/JetpackStats/Views/ChartAxisDateLabel.swift new file mode 100644 index 000000000000..e4b660d8b6eb --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/ChartAxisDateLabel.swift @@ -0,0 +1,61 @@ +import SwiftUI + +struct ChartAxisDateLabel: View { + let date: Date + let granularity: DateRangeGranularity + + @Environment(\.context) var context + + var body: some View { + if granularity == .hour { + hourLabel + } else { + standardLabel + } + } + + private var standardLabel: some View { + Text(context.formatters.date.formatDate(date, granularity: granularity)) + .font(.caption2.weight(.medium)) + .foregroundColor(.secondary) + } + + @ViewBuilder + private var hourLabel: some View { + let formatted = context.formatters.date.formatDate(date, granularity: granularity) + + if let (time, period) = parseHourFormat(formatted) { + (Text(time.uppercased()) + .font(.caption2.weight(.medium)) + + Text(period.lowercased()) + .font(.caption2.weight(.medium).lowercaseSmallCaps())) + .foregroundColor(.secondary) + } else { + standardLabel + } + } + + /// Parses hour format string into time and period components + /// - Parameter formatted: The formatted date string (e.g., "1 PM", "12 AM") + /// - Returns: A tuple of (time, period) if successfully parsed, nil otherwise + private func parseHourFormat(_ formatted: String) -> (time: String, period: String)? { + let components = formatted.split(separator: " ") + guard components.count == 2 else { return nil } + + return (String(components[0]), String(components[1])) + } +} + +#Preview { + VStack(spacing: 20) { + // Hour format + ChartAxisDateLabel(date: Date(), granularity: .hour) + + // Day format + ChartAxisDateLabel(date: Date(), granularity: .day) + + // Month format + ChartAxisDateLabel(date: Date(), granularity: .month) + } + .padding() +} diff --git a/Modules/Sources/JetpackStats/Views/ChartLegendView.swift b/Modules/Sources/JetpackStats/Views/ChartLegendView.swift new file mode 100644 index 000000000000..12d3381b1536 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/ChartLegendView.swift @@ -0,0 +1,36 @@ +import SwiftUI + +struct ChartLegendView: View { + let metric: SiteMetric + let currentPeriod: DateInterval + let previousPeriod: DateInterval + + @Environment(\.context) var context + + var body: some View { + HStack(spacing: 12) { + // Current period + HStack(spacing: 6) { + Circle() + .fill(metric.primaryColor) + .frame(width: 6, height: 6) + Text(context.formatters.dateRange.string(from: currentPeriod)) + .foregroundColor(.primary) + } + .layoutPriority(1) + + // Previous period + HStack(spacing: 6) { + Circle() + .fill(Color.secondary.opacity(0.8)) + .frame(width: 6, height: 6) + Text(context.formatters.dateRange.string(from: previousPeriod)) + .foregroundColor(.secondary) + } + .layoutPriority(0.5) + } + .font(.footnote.weight(.medium)) + .allowsTightening(true) + .lineLimit(1) + } +} diff --git a/Modules/Sources/JetpackStats/Views/ChartValueTooltipView.swift b/Modules/Sources/JetpackStats/Views/ChartValueTooltipView.swift new file mode 100644 index 000000000000..c0c4803ed0c9 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/ChartValueTooltipView.swift @@ -0,0 +1,101 @@ +import SwiftUI + +struct ChartValueTooltipView: View { + let currentPoint: DataPoint? + let previousPoint: DataPoint? + let metric: SiteMetric + let granularity: DateRangeGranularity + + @Environment(\.context) var context + + private var formattedDate: String? { + guard let date = currentPoint?.date ?? previousPoint?.date else { return nil } + return context.formatters.date.formatDate(date, granularity: granularity) + } + + private var trend: TrendViewModel? { + guard let currentPoint, let previousPoint else { + return nil + } + return TrendViewModel( + currentValue: currentPoint.value, + previousValue: previousPoint.value, + metric: metric + ) + } + + private var isIncompleteData: Bool { + guard let date = currentPoint?.date else { return false } + return context.calendar.isIncompleteDataPeriod(for: date, granularity: granularity) + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + // Use the date from whichever point is available + if let formattedDate { + Text(formattedDate) + .font(.subheadline) + .fontWeight(.semibold) + } + + ChartTooltipRow( + color: metric.primaryColor, + value: currentPoint?.value, + isPrimary: true, + metric: metric + ) + + ChartTooltipRow( + color: Color.secondary, + value: previousPoint?.value, + isPrimary: false, + metric: metric + ) + + if let trend { + Text(trend.formattedTrend) + .contentTransition(.numericText()) + .font(.subheadline.weight(.medium)).tracking(-0.33) + .foregroundColor(trend.sentiment.foregroundColor) + } + + if isIncompleteData { + Text(Strings.Chart.incompleteData) + .font(.caption) + .foregroundColor(.secondary) + } + } + .fixedSize() + .padding(8) + .background(Color(.systemBackground)) + .cornerRadius(8) + .shadow(color: Color.black.opacity(0.1), radius: 4, x: 0, y: 2) + } +} + +private struct ChartTooltipRow: View { + let color: Color + let value: Int? + let isPrimary: Bool + let metric: SiteMetric + + var body: some View { + HStack(spacing: 6) { + Circle() + .fill(color) + .frame(width: 8, height: 8) + Text(formattedValue) + .font(.subheadline) + .fontWeight(isPrimary ? .medium : .regular) + .foregroundColor(isPrimary ? .primary : .secondary) + } + } + + private var formattedValue: String { + guard let value else { + return "–" + } + return StatsValueFormatter(metric: metric) + .format(value: value) + } +} diff --git a/Modules/Sources/JetpackStats/Views/ChartValuesSummaryView.swift b/Modules/Sources/JetpackStats/Views/ChartValuesSummaryView.swift new file mode 100644 index 000000000000..fa29dfc3a072 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/ChartValuesSummaryView.swift @@ -0,0 +1,61 @@ +import SwiftUI + +struct ChartValuesSummaryView: View { + let trend: TrendViewModel + var style: SummaryStyle = .standard + + enum SummaryStyle: CaseIterable { + case standard + case compact + } + + var body: some View { + Group { + switch style { + case .standard: + HStack(alignment: .center, spacing: 16) { + Text(trend.formattedCurrentValue) + .font(Font.make(.recoleta, textStyle: .title, weight: .medium)) + .foregroundColor(.primary) + .contentTransition(.numericText()) + + BadgeTrendIndicator(trend: trend) + } + case .compact: + HStack(alignment: .center, spacing: 12) { + Text(trend.formattedCurrentValue) + .font(.subheadline.weight(.medium)) + .foregroundColor(.primary) + .contentTransition(.numericText()) + + Group { + Text(trend.formattedChange) + HStack(spacing: 2) { + Image(systemName: trend.systemImage) + .font(.caption2.weight(.medium)) + Text(trend.formattedPercentage) + } + } + .contentTransition(.numericText()) + .font(.subheadline.weight(.medium)) + .foregroundColor(trend.sentiment.foregroundColor) + } + } + } + .animation(.default, value: trend) + } +} + +#Preview { + VStack(spacing: 20) { + ForEach(ChartValuesSummaryView.SummaryStyle.allCases, id: \.self) { style in + ChartValuesSummaryView(trend: .init(currentValue: 1000, previousValue: 500, metric: .views), style: style) + ChartValuesSummaryView(trend: .init(currentValue: 500, previousValue: 1000, metric: .views), style: style) + ChartValuesSummaryView(trend: .init(currentValue: 100, previousValue: 100, metric: .views), style: style) + ChartValuesSummaryView(trend: .init(currentValue: 56, previousValue: 60, metric: .bounceRate), style: style) + ChartValuesSummaryView(trend: .init(currentValue: 42, previousValue: 0, metric: .views), style: style) + Divider() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) +} diff --git a/Modules/Sources/JetpackStats/Views/CustomDateRangePicker.swift b/Modules/Sources/JetpackStats/Views/CustomDateRangePicker.swift new file mode 100644 index 000000000000..e7948d2532da --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/CustomDateRangePicker.swift @@ -0,0 +1,317 @@ +import SwiftUI + +struct CustomDateRangePicker: View { + @Binding var dateRange: StatsDateRange + + @State private var startDate: Date + @State private var endDate: Date + @State private var showingTimezoneInfo = false + + @Environment(\.dismiss) private var dismiss + @Environment(\.context) private var context + + init(dateRange: Binding) { + self._dateRange = dateRange + let interval = dateRange.wrappedValue.dateInterval + self._startDate = State(initialValue: interval.start) + // The app uses inclusive date periods (e.g., Jan 1 00:00 to Jan 2 00:00 represents all of Jan 1). + // For DatePicker, we subtract 1 second to ensure the end date shows as the last day of the range + // (e.g., Jan 1 instead of Jan 2). The time component is irrelevant since we only pick dates. + self._endDate = State(initialValue: interval.end.addingTimeInterval(-1)) + } + + private var calendar: Calendar { + context.calendar + } + + var body: some View { + NavigationView { + ScrollView { + contents + } + .background(Constants.Colors.statsBackground) + .navigationTitle(Strings.DatePicker.customRange) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(Strings.Buttons.cancel) { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button(Strings.Buttons.apply) { buttonApplyTapped() } + .fontWeight(.semibold) + .buttonStyle(.borderedProminent) + .buttonBorderShape(.capsule) + } + } + } + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } + + @ViewBuilder + private var contents: some View { + VStack(spacing: 16) { + VStack(spacing: 16) { + dateSelectionSection + currentSelectionHeader + } + .padding() + .cardStyle() + + quickPeriodPicker + .padding() + } + .padding(.top, 16) + } + + // MARK: - Actions + + private func buttonApplyTapped() { + let interval = DateInterval(start: startDate, end: { + let date = calendar.startOfDay(for: endDate) + return calendar.date(byAdding: .day, value: 1, to: date) ?? date + }()) + let component = calendar.determineNavigationComponent(for: interval) ?? .day + dateRange = StatsDateRange(interval: interval, component: component, comparison: dateRange.comparison, calendar: calendar) + UIImpactFeedbackGenerator(style: .light).impactOccurred() + dismiss() + } + + // MARK: - View Components + + private var currentSelectionHeader: some View { + VStack(spacing: 5) { + Text(formattedDateCount) + .font(.subheadline.weight(.medium)) + .foregroundStyle(.secondary) + .contentTransition(.numericText()) + .animation(.spring, value: formattedDateCount) + timezoneInfoRow + } + } + + private var timezoneInfoRow: some View { + Button { + showingTimezoneInfo = true + } label: { + HStack(spacing: 8) { + Image(systemName: "location") + .font(.caption2) + Text(formattedTimeZone) + Image(systemName: "info.circle") + .font(.caption) + .foregroundColor(.accentColor) + } + .font(.footnote) + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + .popover(isPresented: $showingTimezoneInfo) { + timezoneInfoContent + } + } + + private var formattedDateCount: String { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.day] + formatter.unitsStyle = .full + formatter.calendar = calendar + formatter.maximumUnitCount = 1 + return formatter.string(from: startDate, to: endDate.addingTimeInterval(1)) ?? "" + } + + private var formattedTimeZone: String { + let name = context.timeZone.localizedName(for: .standard, locale: .current) + return name ?? context.timeZone.identifier + } + + private var dateSelectionSection: some View { + HStack(alignment: .bottom, spacing: 0) { + datePickerColumn(label: Strings.DatePicker.from.uppercased(), selection: $startDate, alignment: .leading) + .onChange(of: startDate) { newValue in + // If start date is after end date, adjust end date to be one day after start + if newValue > endDate { + endDate = calendar.date(byAdding: .day, value: 1, to: newValue) ?? newValue + } + } + + Spacer(minLength: 32) + + datePickerColumn(label: Strings.DatePicker.to.uppercased(), selection: $endDate, alignment: .trailing) + .onChange(of: endDate) { newValue in + // If end date is before start date, adjust start date to be one day before end + if newValue < startDate { + startDate = calendar.date(byAdding: .day, value: -1, to: newValue) ?? newValue + } + } + } + .overlay(alignment: .bottom) { + Image(systemName: "arrow.forward") + .font(.headline.weight(.bold)) + .foregroundColor(.secondary) + .padding(.bottom, 10) + } + } + + private func datePickerColumn(label: String, selection: Binding, alignment: HorizontalAlignment) -> some View { + VStack(alignment: alignment, spacing: 8) { + Text(label) + .font(.caption) + .foregroundColor(.secondary) + .textCase(.uppercase) + + DatePicker("", selection: selection, displayedComponents: .date) + .labelsHidden() + .datePickerStyle(.compact) + .fixedSize() + .environment(\.timeZone, context.timeZone) + } + .lineLimit(1) + } + + @ViewBuilder + private var timezoneInfoContent: some View { + VStack(alignment: .leading, spacing: 12) { + Text(Strings.DatePicker.siteTimeZone) + .font(.headline) + .foregroundStyle(.primary) + + Text(Strings.DatePicker.siteTimeZoneDescription) + .font(.subheadline) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + + Text("\(formattedTimeZone) (\(context.formatters.date.formattedTimeOffset))") + .font(.footnote) + .foregroundColor(.primary) + } + .padding() + .frame(idealWidth: 280, maxWidth: 320) + .modifier(PopoverPresentationModifier()) + } + + // MARK: - Quick Periods + + struct QuickPeriod: Identifiable { + var id: String { name } + let name: String + let action: () -> Void + let datePreview: String + } + + private var quickPeriods: [QuickPeriod] { + return [ + makeQuickPeriod(named: Strings.Calendar.week, component: .weekOfYear), + makeQuickPeriod(named: Strings.Calendar.month, component: .month), + makeQuickPeriod(named: Strings.Calendar.quarter, component: .quarter), + makeQuickPeriod(named: Strings.Calendar.year, component: .year), + ].compactMap { $0 } + } + + private func makeQuickPeriod(named name: String, component: Calendar.Component) -> QuickPeriod? { + guard let interval = calendar.dateInterval(of: component, for: startDate) else { + return nil + } + return QuickPeriod( + name: name, + action: { selectQuickPeriod(component) }, + datePreview: context.formatters.dateRange.string(from: interval) + ) + } + + private var quickPeriodPicker: some View { + VStack(spacing: 12) { + Text(Strings.DatePicker.quickPeriodsForStartDate) + .font(.footnote) + .foregroundColor(.secondary) + + LazyVGrid(columns: [ + GridItem(.flexible(), spacing: 12), + GridItem(.flexible(), spacing: 12) + ], spacing: 12) { + ForEach(quickPeriods) { period in + QuickPeriodButtonView(period: period) + } + } + } + } + + private func selectQuickPeriod(_ component: Calendar.Component) { + guard let interval = calendar.dateInterval(of: component, for: startDate) else { + assertionFailure("invalid interval") + return + } + startDate = interval.start + // Same adjustment as in init: subtract 1 second for DatePicker display + endDate = interval.end.addingTimeInterval(-1) + } +} + +private struct PopoverPresentationModifier: ViewModifier { + func body(content: Content) -> some View { + if #available(iOS 16.4, *) { + content.presentationCompactAdaptation(.popover) + } else { + content + } + } +} + +private struct QuickPeriodButtonView: View { + let period: CustomDateRangePicker.QuickPeriod + + var body: some View { + Button { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + period.action() + } label: { + VStack(spacing: 4) { + Text(period.name) + .font(.callout.weight(.medium)) + Text(period.datePreview) + .font(.footnote) + .foregroundColor(.secondary) + .contentTransition(.numericText()) + .animation(.spring, value: period.datePreview) + } + .lineLimit(1) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .padding(.horizontal, 8) + .background(Color(.tertiarySystemFill)) + .cornerRadius(12) + } + .buttonStyle(.plain) + } +} + +#Preview { + struct PreviewContainer: View { + @State private var showingPicker = true + @State private var dateRange = StatsDateRange(interval: DateInterval(start: Date(), duration: 86400 * 7), component: .day) + + var body: some View { + ZStack { + Color(.systemBackground) + .ignoresSafeArea() + + VStack { + Text("Main View") + .font(.largeTitle) + + Button("Show Date Picker") { + showingPicker = true + } + .buttonStyle(.borderedProminent) + } + } + .sheet(isPresented: $showingPicker) { + CustomDateRangePicker(dateRange: $dateRange) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } + } + } + + return PreviewContainer() +} diff --git a/Modules/Sources/JetpackStats/Views/LegacyFloatingDateControl.swift b/Modules/Sources/JetpackStats/Views/LegacyFloatingDateControl.swift new file mode 100644 index 000000000000..ad7291abb585 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/LegacyFloatingDateControl.swift @@ -0,0 +1,138 @@ +import SwiftUI + +/// A pre-Liquid Glass version. +struct LegacyFloatingDateControl: View { + @Binding var dateRange: StatsDateRange + @State private var isShowingCustomRangePicker = false + + private var buttonHeight: CGFloat { min(_buttonHeight, 60) } + @ScaledMetric private var _buttonHeight = 50 + + @Environment(\.context) var context + + // MARK: - Body + + var body: some View { + HStack(spacing: 0) { + dateRangeButton + Spacer(minLength: 8) + navigationControls + } + .dynamicTypeSize(...DynamicTypeSize.accessibility1) + .padding(.horizontal, 24) + .background { + LinearGradient( + gradient: Gradient(colors: [ + Color(uiColor: .systemBackground).opacity(0.1), + Color(uiColor: .systemBackground).opacity(0.8), + Color(uiColor: .systemBackground).opacity(1) + ]), + startPoint: .top, + endPoint: .bottom + ) + .offset(y: buttonHeight / 4) + .frame(height: buttonHeight * 2 + buttonHeight / 4) + .ignoresSafeArea() + } + .sheet(isPresented: $isShowingCustomRangePicker) { + CustomDateRangePicker(dateRange: $dateRange) + } + } + + // MARK: - Date Range Picker + + private var dateRangeButton: some View { + Menu { + StatsDateRangePickerMenu(selection: $dateRange, isShowingCustomRangePicker: $isShowingCustomRangePicker) + } label: { + dateRangeButtonContent + .contentShape(Rectangle()) + .frame(height: buttonHeight) + } + .tint(Color.primary) + .menuOrder(.fixed) + .buttonStyle(.plain) + .floatingStyle() + } + + private var dateRangeButtonContent: some View { + HStack(spacing: 8) { + Image(systemName: "calendar") + .font(.headline.weight(.regular)) + .foregroundStyle(.primary.opacity(0.8)) + + VStack(alignment: .leading, spacing: 0) { + Text(currentRangeText) + .fontWeight(.medium) + .allowsTightening(true) + } + } + .lineLimit(1) + .padding(.horizontal, 15) + .padding(.trailing, 2) + } + + private var currentRangeText: String { + context.formatters.dateRange.string(from: dateRange.dateInterval) + } + + // MARK: - Navigation Controls + + private var navigationControls: some View { + HStack(spacing: 8) { + makeNavigationButton(direction: .backward) + makeNavigationButton(direction: .forward) + } + .floatingStyle() + } + + private func makeNavigationButton(direction: Calendar.NavigationDirection) -> some View { + let isDisabled = !dateRange.canNavigate(in: direction) + return Menu { + ForEach(dateRange.availableAdjacentPeriods(in: direction)) { period in + Button(period.displayText) { + dateRange = period.range + } + } + } label: { + Image(systemName: direction.systemImage) + .font(.title3.weight(.medium)) + .foregroundColor(isDisabled ? Color(.quaternaryLabel) : Color(.label)) + .frame(width: 48) + .frame(height: buttonHeight) + .opacity(isDisabled ? 0.5 : 1.0) + .contentShape(Rectangle()) + } primaryAction: { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + dateRange = dateRange.navigate(direction) + } + .disabled(isDisabled) + } +} + +private struct FloatingStyle: ViewModifier { + let cornerRadius: CGFloat + + init(cornerRadius: CGFloat = 40) { + self.cornerRadius = cornerRadius + } + + func body(content: Content) -> some View { + content + .background(Color(.systemBackground).opacity(0.2)) + .background(Material.thin) + .overlay { + RoundedRectangle(cornerRadius: cornerRadius) + .stroke(Color(.separator).opacity(0.2), lineWidth: 1) + } + .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) + .shadow(color: Color.black.opacity(0.1), radius: 8, x: 0, y: 4) + .shadow(color: Color.black.opacity(0.05), radius: 4, x: 0, y: 2) + } +} + +private extension View { + func floatingStyle(cornerRadius: CGFloat = 40) -> some View { + modifier(FloatingStyle(cornerRadius: cornerRadius)) + } +} diff --git a/Modules/Sources/JetpackStats/Views/MetricsOverviewTabView.swift b/Modules/Sources/JetpackStats/Views/MetricsOverviewTabView.swift new file mode 100644 index 000000000000..581ddf7103dc --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/MetricsOverviewTabView.swift @@ -0,0 +1,158 @@ +import SwiftUI +import WordPressUI + +/// A horizontal scrollable tab view displaying metric summaries with values and trends. +/// +/// Each tab shows a metric's current value, percentage change, and visual selection indicator. +struct MetricsOverviewTabView: View { + /// Data for a single metric tab + struct MetricData { + let metric: SiteMetric + let value: Int? + let previousValue: Int? + } + + let data: [MetricData] + @Binding var selectedMetric: SiteMetric + + @ScaledMetric(relativeTo: .title) private var minTabWidth: CGFloat = 102 + + var body: some View { + ScrollViewReader { proxy in + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 0) { + ForEach(data, id: \.metric) { item in + makeItemView(for: item) { + selectDataType(item.metric, proxy: proxy) + } + } + } + } + } + } + + private func makeItemView(for item: MetricData, onTap: @escaping () -> Void) -> some View { + MetricItemView(data: item, isSelected: selectedMetric == item.metric, onTap: onTap) + .frame(minWidth: minTabWidth) + .id(item.metric) + } + + private func selectDataType(_ type: SiteMetric, proxy: ScrollViewProxy) { + withAnimation(.smooth) { + selectedMetric = type + proxy.scrollTo(type, anchor: .center) + } + UIImpactFeedbackGenerator(style: .soft).impactOccurred() + } +} + +private struct MetricItemView: View { + let data: MetricsOverviewTabView.MetricData + let isSelected: Bool + let onTap: () -> Void + + private var valueFormatter: StatsValueFormatter { + StatsValueFormatter(metric: data.metric) + } + + private var formattedValue: String { + guard let value = data.value else { return "–" } + return valueFormatter.format(value: value, context: .compact) + } + + private var trend: TrendViewModel? { + guard let value = data.value, let previousValue = data.previousValue else { return nil } + return TrendViewModel(currentValue: value, previousValue: previousValue, metric: data.metric) + } + + var body: some View { + Button(action: onTap) { + VStack(alignment: .leading, spacing: 0) { + tabContent + .padding(.vertical, Constants.step2) + .padding(.leading, Constants.step2) + selectionIndicator + .padding(.horizontal, Constants.step2) + } + } + .buttonStyle(.plain) + } + + // MARK: - Private Views + + private var tabContent: some View { + VStack(alignment: .leading, spacing: 2) { + headerView + .unredacted() + metricsView + } + } + + private var headerView: some View { + HStack(spacing: 2) { + Image(systemName: data.metric.systemImage) + .font(.caption2.weight(.medium)) + Text(data.metric.localizedTitle.uppercased()) + .font(.caption.weight(.medium)) + } + .foregroundColor(isSelected ? .primary : .secondary) + .animation(.smooth, value: isSelected) + .padding(.trailing, 4) // Visually spacing matters less than for metricsView + } + + private var metricsView: some View { + VStack(alignment: .leading, spacing: 2) { + Text(formattedValue) + .contentTransition(.numericText()) + .font(Font.make(.recoleta, textStyle: .title, weight: .medium)) + .foregroundColor(.primary) + .lineLimit(1) + .animation(.spring, value: formattedValue) + + if let trend { + BadgeTrendIndicator(trend: trend) + } else { + // Placeholder for loading state + BadgeTrendIndicator( + trend: TrendViewModel(currentValue: 125, previousValue: 100, metric: data.metric) + ) + .grayscale(1) + .redacted(reason: .placeholder) + } + } + .padding(.trailing, 8) + } + + private var selectionIndicator: some View { + Rectangle() + .fill(isSelected ? Color.primary : Color.clear) + .frame(height: 3) + .cornerRadius(1.5) + } +} + +// MARK: - Preview Support + +#if DEBUG + +#Preview { + let mockData: [MetricsOverviewTabView.MetricData] = [ + .init(metric: .views, value: 128400, previousValue: 142600), + .init(metric: .visitors, value: 49800, previousValue: 54200), + .init(metric: .likes, value: nil, previousValue: nil), + .init(metric: .comments, value: 210, previousValue: nil), + .init(metric: .timeOnSite, value: 165, previousValue: 148), + .init(metric: .bounceRate, value: nil, previousValue: 72) + ] + + MetricsOverviewTabView( + data: mockData, + selectedMetric: .constant(.views) + ) + .background(Color(.systemBackground)) + .cardStyle() + .frame(maxHeight: .infinity, alignment: .center) + .background(Constants.Colors.statsBackground) +} + +#endif diff --git a/Modules/Sources/JetpackStats/Views/SimpleErrorView.swift b/Modules/Sources/JetpackStats/Views/SimpleErrorView.swift new file mode 100644 index 000000000000..8d0b3fb12c08 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/SimpleErrorView.swift @@ -0,0 +1,14 @@ +import SwiftUI + +struct SimpleErrorView: View { + let error: Error + + var body: some View { + Text(error.localizedDescription) + .font(.subheadline.weight(.medium)) + .multilineTextAlignment(.center) + .frame(maxWidth: 300) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/Modules/Sources/JetpackStats/Views/StatsCardTitleView.swift b/Modules/Sources/JetpackStats/Views/StatsCardTitleView.swift new file mode 100644 index 000000000000..965d92fbdc69 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/StatsCardTitleView.swift @@ -0,0 +1,81 @@ +import SwiftUI + +struct StatsCardTitleView: View { + let title: String + let showChevron: Bool + + init(title: String, showChevron: Bool = false) { + self.title = title + self.showChevron = showChevron + } + + var body: some View { + HStack(alignment: .center) { + content + } + .tint(Color.primary) + .lineLimit(1) + } + + @ViewBuilder + private var content: some View { + let title = Text(title) + .font(.headline) + if showChevron { + // Note: had to do that to fix the animation issuse with Menu + // hiding the image. + title + Text(" ") + Text(Image(systemName: "chevron.up.chevron.down")) + .font(.footnote.weight(.semibold)) + .foregroundColor(.secondary) + .baselineOffset(1) + } else { + title + } + } +} + +struct InlineValuePickerTitle: View { + let title: String + + init(title: String) { + self.title = title + } + + var body: some View { + HStack(alignment: .center) { + content + } + .tint(Color.primary) + .lineLimit(1) + } + + @ViewBuilder + private var content: some View { + let title = Text(title) + .font(.subheadline) + .fontWeight(.medium) + + // Note: had to do that to fix the animation issuse with Menu + // hiding the image. + title + Text(" ") + Text(Image(systemName: "chevron.up.chevron.down")) + .font(.footnote.weight(.semibold)) + .foregroundColor(.secondary) + .baselineOffset(1) + } +} + +#Preview { + VStack(spacing: 20) { + StatsCardTitleView(title: "Posts & Pages", showChevron: true) + .padding() + .background(Color(.systemBackground)) + .cornerRadius(12) + + StatsCardTitleView(title: "Referrers", showChevron: false) + .padding() + .background(Color(.systemBackground)) + .cornerRadius(12) + } + .padding() + .background(Color(.systemGroupedBackground)) +} diff --git a/Modules/Sources/JetpackStats/Views/StatsDateRangePickerMenu.swift b/Modules/Sources/JetpackStats/Views/StatsDateRangePickerMenu.swift new file mode 100644 index 000000000000..c5d18ccda515 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/StatsDateRangePickerMenu.swift @@ -0,0 +1,83 @@ +import SwiftUI + +struct StatsDateRangePickerMenu: View { + @Binding var selection: StatsDateRange + @Binding var isShowingCustomRangePicker: Bool + + @Environment(\.context) var context + + var body: some View { + Section { + Button { + isShowingCustomRangePicker = true + } label: { + Label(Strings.DatePicker.customRangeMenu, systemImage: "calendar") + } + comparisonPeriodPicker + } + Section { + makePresetButtons(for: [ + .last7Days, + .last30Days, + .last12Months, + ]) + Menu { + Section { + makePresetButtons(for: [ + .last28Days, + .last90Days, + .last6Months, + .last5Years, + .last10Years + ]) + } + } label: { + Text(Strings.DatePicker.morePeriods) + } + } + Section { + makePresetButtons(for: [ + .today, + .thisWeek, + .thisMonth, + .thisYear + ]) + } + } + + private func makePresetButtons(for presents: [DateIntervalPreset]) -> some View { + ForEach(presents) { preset in + Button(preset.localizedString) { + selection.update(preset: preset) + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } + } + } + + private var comparisonPeriodPicker: some View { + Menu { + ForEach(DateRangeComparisonPeriod.allCases) { period in + Button(action: { + selection.update(comparisonPeriod: period) + UIImpactFeedbackGenerator(style: .light).impactOccurred() + }) { + Text(period.localizedTitle) + Text(formattedComparisonRange(for: period)) + if selection.comparison == period { + Image(systemName: "checkmark") + } + } + .lineLimit(1) + .disabled(!selection.isComparisonPeriodEnabled(period)) + } + } label: { + Label(Strings.DatePicker.compareWith, systemImage: "arrow.left.arrow.right") + } + } + + private func formattedComparisonRange(for period: DateRangeComparisonPeriod) -> String { + var copy = selection + copy.update(comparisonPeriod: period) + return context.formatters.dateRange.string(from: copy.effectiveComparisonInterval) + } +} diff --git a/Modules/Sources/JetpackStats/Views/StatsTabBar.swift b/Modules/Sources/JetpackStats/Views/StatsTabBar.swift new file mode 100644 index 000000000000..ad1a8846024a --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/StatsTabBar.swift @@ -0,0 +1,78 @@ +import SwiftUI + +enum StatsTab: CaseIterable { + case traffic + case realtime + case insights + case subscribers + + var localizedTitle: String { + switch self { + case .traffic: return Strings.Tabs.traffic + case .realtime: return Strings.Tabs.realtime + case .insights: return Strings.Tabs.insights + case .subscribers: return Strings.Tabs.subscribers + } + } +} + +struct StatsTabBar: View { + @Binding var selectedTab: StatsTab + var showBackground: Bool = true + + var body: some View { + VStack(spacing: 0) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 18) { + ForEach(StatsTab.allCases, id: \.self) { tab in + tabButton(for: tab) + } + } + .padding(.horizontal, 32) + } + Divider() + } + .background { + backgroundView + } + } + + @ViewBuilder + private func tabButton(for tab: StatsTab) -> some View { + Button(action: { + selectedTab = tab + UIImpactFeedbackGenerator(style: .soft).impactOccurred() + }) { + VStack(spacing: 8) { + ZStack { + // Reserver enough space for the semibold version + Text(tab.localizedTitle) + .font(.headline.weight(.semibold)) + .foregroundColor(.primary) + .opacity(selectedTab == tab ? 1 : 0) + + Text(tab.localizedTitle) + .font(.headline.weight(.regular)) + .foregroundColor(.secondary) + .opacity(selectedTab == tab ? 0 : 1) + } + + Rectangle() + .fill(selectedTab == tab ? Color.primary : Color.clear) + .frame(height: 2) + .cornerRadius(1.5) + } + .animation(.smooth, value: selectedTab) + } + } + + private var backgroundView: some View { + Rectangle() + .fill(Material.ultraThin) + .ignoresSafeArea(edges: .top) + .frame(maxHeight: .infinity) + .offset(y: -100) + .padding(.bottom, -100) + .opacity(showBackground ? 1 : 0) + } +} diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListAuthorRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListAuthorRowView.swift new file mode 100644 index 000000000000..75b0d451f954 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListAuthorRowView.swift @@ -0,0 +1,26 @@ +import SwiftUI + +struct TopListAuthorRowView: View { + let item: TopListData.Author + let showDetails: Bool + + var body: some View { + HStack(spacing: 12) { + AvatarView(name: item.name, imageURL: item.avatarURL) + + VStack(alignment: .leading, spacing: 2) { + Text(item.name) + .font(.callout) + .foregroundColor(.primary) + .lineLimit(1) + + if showDetails, let role = item.role { + Text(role) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + } + } +} diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListExternalLinkRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListExternalLinkRowView.swift new file mode 100644 index 000000000000..3eb45f26449b --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListExternalLinkRowView.swift @@ -0,0 +1,29 @@ +import SwiftUI + +struct TopListExternalLinkRowView: View { + let item: TopListData.ExternalLink + let showDetails: Bool + + var body: some View { + HStack(spacing: 12) { + Image(systemName: "arrow.up.right.square") + .font(.title3) + .foregroundColor(.secondary) + .frame(width: 36, height: 36) + + VStack(alignment: .leading, spacing: 2) { + Text(item.title ?? item.url) + .font(.callout) + .foregroundColor(.primary) + .lineLimit(1) + + if showDetails { + Text(item.url) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + } + } +} diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListLocationRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListLocationRowView.swift new file mode 100644 index 000000000000..a4372c3c16d0 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListLocationRowView.swift @@ -0,0 +1,28 @@ +import SwiftUI + +struct TopListLocationRowView: View { + let item: TopListData.Location + let showDetails: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + if let flag = item.flag { + Text(flag) + .font(.callout) + } + Text(item.country) + .font(.callout) + .foregroundColor(.primary) + .lineLimit(1) + } + + if showDetails, let countryCode = item.countryCode { + Text(countryCode) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + } +} diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListPostRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListPostRowView.swift new file mode 100644 index 000000000000..33e24e6150d6 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListPostRowView.swift @@ -0,0 +1,22 @@ +import SwiftUI + +struct TopListPostRowView: View { + let item: TopListData.Post + let showDetails: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(item.title) + .font(.callout) + .foregroundColor(.primary) + .lineLimit(1) + + if showDetails, let author = item.author { + Text(verbatim: author) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + } +} diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListReferrerRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListReferrerRowView.swift new file mode 100644 index 000000000000..768a863d3a34 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListReferrerRowView.swift @@ -0,0 +1,22 @@ +import SwiftUI + +struct TopListReferrerRowView: View { + let item: TopListData.Referrer + let showDetails: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(item.name) + .font(.callout) + .foregroundColor(.primary) + .lineLimit(1) + + if showDetails, let domain = item.domain { + Text(domain) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + } +} diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListItemBarBackground.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListItemBarBackground.swift new file mode 100644 index 000000000000..e3b1dedaa364 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemBarBackground.swift @@ -0,0 +1,29 @@ +import SwiftUI + +struct TopListItemBarBackground: View { + let value: Int + let maxValue: Int + let barColor: Color + + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + GeometryReader { geometry in + HStack(spacing: 0) { + RoundedRectangle(cornerRadius: 6) + .fill(barColor.opacity(colorScheme == .light ? 0.09 : 0.5)) + .frame(width: barWidth(in: geometry)) + .animation(.spring(response: 0.4, dampingFraction: 0.8), value: value) + Spacer(minLength: 0) + } + } + } + + private func barWidth(in geometry: GeometryProxy) -> CGFloat { + guard maxValue > 0 else { + return 0 + } + let value = geometry.size.width * CGFloat(value) / CGFloat(maxValue) + return max(0, value) + } +} diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift new file mode 100644 index 000000000000..e86b07d02adc --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift @@ -0,0 +1,49 @@ +import SwiftUI + +struct TopListItemView: View { + let currentItem: any TopListItem + let previousItem: (any TopListItem)? + let metric: SiteMetric + let maxValue: Int + let showDetails: Bool + + var body: some View { + HStack(spacing: 0) { + // Content-specific view + switch currentItem { + case let post as TopListData.Post: + TopListPostRowView(item: post, showDetails: showDetails) + case let referrer as TopListData.Referrer: + TopListReferrerRowView(item: referrer, showDetails: showDetails) + case let location as TopListData.Location: + TopListLocationRowView(item: location, showDetails: showDetails) + case let author as TopListData.Author: + TopListAuthorRowView(item: author, showDetails: showDetails) + case let link as TopListData.ExternalLink: + TopListExternalLinkRowView(item: link, showDetails: showDetails) + default: + let _ = assertionFailure("unsupported item: \(currentItem)") + EmptyView() + } + + Spacer(minLength: 4) + + // Metrics view + TopListMetricsView( + currentValue: currentItem.metrics[metric] ?? 0, + previousValue: previousItem?.metrics[metric], + metric: metric, + showDetails: showDetails + ) + } + .padding(.vertical, 7) + .background( + TopListItemBarBackground( + value: currentItem.metrics[metric] ?? 0, + maxValue: maxValue, + barColor: metric.primaryColor + ) + .padding(.horizontal, -(Constants.step2 / 2)) + ) + } +} diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift new file mode 100644 index 000000000000..02bc96dec3b0 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift @@ -0,0 +1,69 @@ +import SwiftUI + +struct TopListItemsView: View { + let data: TopListChartData + let itemLimit: Int + let showDetails: Bool + let showMoreButton: Bool + let onShowMore: (() -> Void)? + + var body: some View { + VStack(spacing: 0) { + VStack(spacing: Constants.step1 / 2) { + ForEach(data.items.prefix(itemLimit), id: \.current.id) { item in + TopListItemView( + currentItem: item.current, + previousItem: item.previous, + metric: data.metric, + maxValue: data.maxValue, + showDetails: showDetails + ) + .transition(.opacity) + } + // Add empty rows if needed to maintain consistent height + let itemsToShow = data.items.prefix(itemLimit).count + if itemsToShow < itemLimit { + ForEach(itemsToShow.. 0) + #expect(response.items.count <= 20, "Should return maximum 20 items") + + // THEN all items are posts + for item in response.items { + if let post = item as? TopListData.Post { + #expect(!post.title.isEmpty) + #expect((post.metrics.views ?? 0) > 0) + } else { + Issue.record("Expected post item but got \(type(of: item))") + } + } + + } + + @Test("Verify getChartData returns valid data for views metric with today range") + func testGetChartDataViewsToday() async throws { + // GIVEN + let service = MockStatsService(timeZone: .current) + let dateInterval = calendar.makeDateInterval(for: .today) + let granularity = dateInterval.preferredGranularity + + // WHEN + let response = try await service.getSiteStats(interval: dateInterval, granularity: granularity) + + // THEN - Basic validations + #expect(response.metrics.count > 0, "Should return at least one data point") + } +} diff --git a/Modules/Tests/JetpackStatsTests/StatsDataAggregationTests.swift b/Modules/Tests/JetpackStatsTests/StatsDataAggregationTests.swift new file mode 100644 index 000000000000..29fb3b4ae2a5 --- /dev/null +++ b/Modules/Tests/JetpackStatsTests/StatsDataAggregationTests.swift @@ -0,0 +1,250 @@ +import Testing +import Foundation +@testable import JetpackStats + +@Suite +struct StatsDataAggregationTests { + let calendar = Calendar.mock(timeZone: TimeZone(secondsFromGMT: 0)!) + + @Test + func hourlyAggregation() { + let aggregator = StatsDataAggregator(calendar: calendar) + + // Create test data with multiple values in the same hour + let date1 = Date("2025-01-15T14:15:00Z") + let date2 = Date("2025-01-15T14:30:00Z") + let date3 = Date("2025-01-15T14:45:00Z") + let date4 = Date("2025-01-15T15:10:00Z") + + let testData: [Date: Int] = [ + date1: 100, + date2: 200, + date3: 150, + date4: 300 + ] + + let aggregated = aggregator.aggregate(testData, granularity: .hour) + + // Should have 2 hours worth of data + #expect(aggregated.count == 2) + + // Check hour 14:00 + let hour14 = Date("2025-01-15T14:00:00Z") + #expect(aggregated[hour14]?.sum == 450) // 100 + 200 + 150 + #expect(aggregated[hour14]?.count == 3) + + // Check hour 15:00 + let hour15 = Date("2025-01-15T15:00:00Z") + #expect(aggregated[hour15]?.sum == 300) + #expect(aggregated[hour15]?.count == 1) + } + + @Test + func dailyAggregation() { + let aggregator = StatsDataAggregator(calendar: calendar) + + // Create test data across multiple days + let testData: [Date: Int] = [ + Date("2025-01-15T08:00:00Z"): 100, + Date("2025-01-15T14:00:00Z"): 200, + Date("2025-01-15T20:00:00Z"): 150, + Date("2025-01-16T10:00:00Z"): 300 + ] + + let aggregated = aggregator.aggregate(testData, granularity: .day) + + #expect(aggregated.count == 2) + + let day1 = Date("2025-01-15T00:00:00Z") + let day2 = Date("2025-01-16T00:00:00Z") + + #expect(aggregated[day1]?.sum == 450) + #expect(aggregated[day1]?.count == 3) + #expect(aggregated[day2]?.sum == 300) + #expect(aggregated[day2]?.count == 1) + } + + @Test + func monthlyAggregation() { + let aggregator = StatsDataAggregator(calendar: calendar) + + let testData: [Date: Int] = [ + Date("2025-01-15T08:00:00Z"): 100, + Date("2025-01-20T14:00:00Z"): 200, + Date("2025-02-10T10:00:00Z"): 300 + ] + + let aggregated = aggregator.aggregate(testData, granularity: .month) + + #expect(aggregated.count == 2) + + let jan = Date("2025-01-01T00:00:00Z") + let feb = Date("2025-02-01T00:00:00Z") + + #expect(aggregated[jan]?.sum == 300) + #expect(aggregated[jan]?.count == 2) + #expect(aggregated[feb]?.sum == 300) + #expect(aggregated[feb]?.count == 1) + } + + @Test + func yearlyAggregation() { + let aggregator = StatsDataAggregator(calendar: calendar) + + let testData: [Date: Int] = [ + Date("2025-01-15T08:00:00Z"): 100, + Date("2025-03-20T14:00:00Z"): 200, + Date("2025-05-10T10:00:00Z"): 300 + ] + + let aggregated = aggregator.aggregate(testData, granularity: .year) + + // Year granularity aggregates by month + #expect(aggregated.count == 1) + + let jan = Date("2025-01-01T00:00:00Z") + + #expect(aggregated[jan]?.sum == 600) + } + + // MARK: - Date Sequence Generation Tests + + @Test + func hourlyDateSequence() { + let aggregator = StatsDataAggregator(calendar: calendar) + let start = Date("2025-01-15T10:00:00Z") + let end = Date("2025-01-15T14:00:00Z") // Exclusive upper bound + + let sequence = aggregator.generateDateSequence(dateInterval: DateInterval(start: start, end: end), by: .hour) + + #expect(sequence.count == 4) // 10:00, 11:00, 12:00, 13:00 + #expect(sequence.first == start) + #expect(sequence.last == Date("2025-01-15T13:00:00Z")) + } + + @Test + func dailyDateSequence() { + let aggregator = StatsDataAggregator(calendar: calendar) + let start = Date("2025-01-15T00:00:00Z") // Already normalized + let end = Date("2025-01-17T00:00:00Z") // Exclusive upper bound + + let sequence = aggregator.generateDateSequence(dateInterval: DateInterval(start: start, end: end), by: .day) + + #expect(sequence.count == 2) // Jan 15, 16 (Jan 17 is excluded as end is exclusive) + #expect(sequence.first == Date("2025-01-15T00:00:00Z")) + #expect(sequence.last == Date("2025-01-16T00:00:00Z")) + } + + @Test + func monthlyDateSequence() { + let aggregator = StatsDataAggregator(calendar: calendar) + let start = Date("2025-01-01T00:00:00Z") // Already normalized + let end = Date("2025-03-01T00:00:00Z") // Exclusive upper bound + + let sequence = aggregator.generateDateSequence(dateInterval: DateInterval(start: start, end: end), by: .month) + + #expect(sequence.count == 2) // Jan, Feb (Mar is excluded as end is exclusive) + #expect(sequence.first == Date("2025-01-01T00:00:00Z")) + #expect(sequence.last == Date("2025-02-01T00:00:00Z")) + } + + @Test + func yearlyDateSequence() { + let aggregator = StatsDataAggregator(calendar: calendar) + let start = Date("2025-01-01T00:00:00Z") + let end = Date("2025-06-01T00:00:00Z") // Exclusive upper bound + + // Year granularity uses month increments + let sequence = aggregator.generateDateSequence(dateInterval: DateInterval(start: start, end: end), by: .month) + + #expect(sequence.count == 5) // Jan, Feb, Mar, Apr, May (Jun is excluded) + #expect(sequence.first == Date("2025-01-01T00:00:00Z")) + #expect(sequence[1] == Date("2025-02-01T00:00:00Z")) + #expect(sequence[2] == Date("2025-03-01T00:00:00Z")) + #expect(sequence[3] == Date("2025-04-01T00:00:00Z")) + #expect(sequence.last == Date("2025-05-01T00:00:00Z")) + } + + @Test + func dateSequenceExcludesEndDate() { + let aggregator = StatsDataAggregator(calendar: calendar) + let start = Date("2025-01-15T00:00:00Z") + let end = Date("2025-01-17T00:00:00Z") // Exclusive upper bound + + let sequence = aggregator.generateDateSequence(dateInterval: DateInterval(start: start, end: end), by: .day) + + // Should include Jan 15, 16 only (DateInterval end is exclusive) + #expect(sequence.count == 2) + #expect(sequence.contains(Date("2025-01-15T00:00:00Z"))) + #expect(sequence.contains(Date("2025-01-16T00:00:00Z"))) + #expect(!sequence.contains(Date("2025-01-17T00:00:00Z"))) + } + + @Test + func dateSequenceWithNonNormalizedStart() { + let aggregator = StatsDataAggregator(calendar: calendar) + // Test with non-normalized start times + let start = Date("2025-01-15T14:30:00Z") // Mid-day + let end = Date("2025-01-18T14:30:00Z") + + let sequence = aggregator.generateDateSequence(dateInterval: DateInterval(start: start, end: end), by: .day) + + // Should start from the given time and increment by days + #expect(sequence.count == 3) + #expect(sequence[0] == Date("2025-01-15T14:30:00Z")) + #expect(sequence[1] == Date("2025-01-16T14:30:00Z")) + #expect(sequence[2] == Date("2025-01-17T14:30:00Z")) + } + + // MARK: - Aggregation Helper Tests + + @Test + func aggregateByComponents() { + let aggregator = StatsDataAggregator(calendar: calendar) + + let testData: [Date: Int] = [ + Date("2025-01-15T14:15:00Z"): 100, + Date("2025-01-15T14:30:00Z"): 200, + Date("2025-01-15T15:10:00Z"): 300 + ] + + let aggregated = aggregator.aggregateByComponents( + testData, + components: [Calendar.Component.year, Calendar.Component.month, Calendar.Component.day, Calendar.Component.hour] + ) + + #expect(aggregated.count == 2) + } + + @Test + func normalizeForMetric_regularMetrics() { + let aggregator = StatsDataAggregator(calendar: calendar) + + let aggregatedData: [Date: AggregatedDataPoint] = [ + Date("2025-01-15T00:00:00Z"): AggregatedDataPoint(sum: 600, count: 3), + Date("2025-01-16T00:00:00Z"): AggregatedDataPoint(sum: 900, count: 3) + ] + + // For regular metrics (views, visitors, etc), values should not change + let normalized = aggregator.normalizeForMetric(aggregatedData, metric: SiteMetric.views) + + #expect(normalized[Date("2025-01-15T00:00:00Z")] == 600) + #expect(normalized[Date("2025-01-16T00:00:00Z")] == 900) + } + + @Test + func normalizeForMetric_averagedMetrics() { + let aggregator = StatsDataAggregator(calendar: calendar) + + let aggregatedData: [Date: AggregatedDataPoint] = [ + Date("2025-01-15T00:00:00Z"): AggregatedDataPoint(sum: 600, count: 3), + Date("2025-01-16T00:00:00Z"): AggregatedDataPoint(sum: 900, count: 3) + ] + + // For timeOnSite and bounceRate, values should be averaged + let normalized = aggregator.normalizeForMetric(aggregatedData, metric: SiteMetric.timeOnSite) + + #expect(normalized[Date("2025-01-15T00:00:00Z")] == 200) // 600/3 + #expect(normalized[Date("2025-01-16T00:00:00Z")] == 300) // 900/3 + } +} diff --git a/Modules/Tests/JetpackStatsTests/StatsDateFormatterTests.swift b/Modules/Tests/JetpackStatsTests/StatsDateFormatterTests.swift new file mode 100644 index 000000000000..94f93652a5f6 --- /dev/null +++ b/Modules/Tests/JetpackStatsTests/StatsDateFormatterTests.swift @@ -0,0 +1,43 @@ +import Testing +import Foundation +@testable import JetpackStats + +@Suite +struct StatsDateFormatterTests { + let formatter = StatsDateFormatter( + locale: Locale(identifier: "en_us"), + timeZone: .eastern + ) + + @Test func hourFormatting() { + let date = Date("2025-03-15T14:00:00-03:00") + let result = formatter.formatDate(date, granularity: .hour) + #expect(result == "2 PM") + + let midnight = Date("2025-03-15T00:00:00-03:00") + let midnightResult = formatter.formatDate(midnight, granularity: .hour) + #expect(midnightResult == "12 AM") + + let noon = Date("2025-03-15T12:00:00-03:00") + let noonResult = formatter.formatDate(noon, granularity: .hour) + #expect(noonResult == "12 PM") + } + + @Test func dayFormatting() { + let date = Date("2025-03-15T14:00:00-03:00") + let result = formatter.formatDate(date, granularity: .day) + #expect(result == "Mar 15") + } + + @Test func monthFormatting() { + let date = Date("2025-03-15T14:00:00-03:00") + let result = formatter.formatDate(date, granularity: .month) + #expect(result == "Mar") + } + + @Test func yearFormatting() { + let date = Date("2025-03-15T14:00:00-03:00") + let result = formatter.formatDate(date, granularity: .year) + #expect(result == "2025") + } +} diff --git a/Modules/Tests/JetpackStatsTests/StatsDateRangeFormatterTests.swift b/Modules/Tests/JetpackStatsTests/StatsDateRangeFormatterTests.swift new file mode 100644 index 000000000000..4420c948d39c --- /dev/null +++ b/Modules/Tests/JetpackStatsTests/StatsDateRangeFormatterTests.swift @@ -0,0 +1,228 @@ +import Testing +import Foundation +@testable import JetpackStats + +@Suite +struct StatsDateRangeFormatterTests { + let calendar = Calendar.mock(timeZone: .eastern) + let locale = Locale(identifier: "en_US") + + // MARK: - Date Range Formatting + + @Test("Date range formatting", arguments: [ + // Single day + (Date("2025-01-01T00:00:00-03:00"), Date("2025-01-02T00:00:00-03:00"), "Jan 1"), + // Same month range + (Date("2025-01-01T00:00:00-03:00"), Date("2025-01-06T00:00:00-03:00"), "Jan 1 – 5"), + (Date("2025-01-03T00:00:00-03:00"), Date("2025-01-13T00:00:00-03:00"), "Jan 3 – 12"), + // Cross month range + (Date("2025-01-31T00:00:00-03:00"), Date("2025-02-03T00:00:00-03:00"), "Jan 31 – Feb 2"), + // Cross year range + (Date("2024-12-31T00:00:00-03:00"), Date("2025-01-03T00:00:00-03:00"), "Dec 31, 2024 – Jan 2, 2025"), + // Same year, different months + (Date("2025-03-15T00:00:00-03:00"), Date("2025-05-20T00:00:00-03:00"), "Mar 15 – May 19") + ]) + func dateRangeFormatting(startDate: Date, endDate: Date, expected: String) { + let interval = DateInterval(start: startDate, end: endDate) + let formatter = StatsDateRangeFormatter(locale: locale, timeZone: calendar.timeZone) + #expect(formatter.string(from: interval) == expected) + } + + // MARK: - Special Period Formatting + + @Test("Entire month formatting", arguments: [ + (Date("2025-01-01T00:00:00-03:00"), Date("2025-02-01T00:00:00-03:00"), "Jan 2025"), + (Date("2024-02-01T00:00:00-03:00"), Date("2024-03-01T00:00:00-03:00"), "Feb 2024"), // Leap year + (Date("2023-12-01T00:00:00-03:00"), Date("2024-01-01T00:00:00-03:00"), "Dec 2023"), + (Date("2025-04-01T00:00:00-03:00"), Date("2025-05-01T00:00:00-03:00"), "Apr 2025") // 30-day month + ]) + func entireMonthFormatting(startDate: Date, endDate: Date, expected: String) { + let interval = DateInterval(start: startDate, end: endDate) + let formatter = StatsDateRangeFormatter(locale: locale, timeZone: calendar.timeZone) + #expect(formatter.string(from: interval) == expected) + } + + @Test("Multiple full months formatting", arguments: [ + // Two months + (Date("2025-01-01T00:00:00-03:00"), Date("2025-03-01T00:00:00-03:00"), "Jan – Feb 2025"), + // Three months + (Date("2025-01-01T00:00:00-03:00"), Date("2025-04-01T00:00:00-03:00"), "Jan – Mar 2025"), + // Five months + (Date("2025-01-01T00:00:00-03:00"), Date("2025-06-01T00:00:00-03:00"), "Jan – May 2025"), + // Cross-year multiple months + (Date("2024-11-01T00:00:00-03:00"), Date("2025-02-01T00:00:00-03:00"), "Nov 2024 – Jan 2025"), + // Full quarter + (Date("2025-04-01T00:00:00-03:00"), Date("2025-07-01T00:00:00-03:00"), "Apr – Jun 2025"), + // Most of year + (Date("2025-02-01T00:00:00-03:00"), Date("2025-12-01T00:00:00-03:00"), "Feb – Nov 2025") + ]) + func multipleFullMonthsFormatting(startDate: Date, endDate: Date, expected: String) { + let interval = DateInterval(start: startDate, end: endDate) + let formatter = StatsDateRangeFormatter(locale: locale, timeZone: calendar.timeZone) + #expect(formatter.string(from: interval) == expected) + } + + @Test("Entire week formatting", arguments: [ + // Monday to Monday (full week) + (Date("2025-01-13T00:00:00-03:00"), Date("2025-01-20T00:00:00-03:00"), "Jan 13 – 19"), + // Sunday to Sunday (full week starting Sunday) + (Date("2025-01-12T00:00:00-03:00"), Date("2025-01-19T00:00:00-03:00"), "Jan 12 – 18"), + // Week crossing months + (Date("2025-01-27T00:00:00-03:00"), Date("2025-02-03T00:00:00-03:00"), "Jan 27 – Feb 2"), + // Week crossing years + (Date("2024-12-30T00:00:00-03:00"), Date("2025-01-06T00:00:00-03:00"), "Dec 30, 2024 – Jan 5, 2025") + ]) + func entireWeekFormatting(startDate: Date, endDate: Date, expected: String) { + let interval = DateInterval(start: startDate, end: endDate) + let formatter = StatsDateRangeFormatter(locale: locale, timeZone: calendar.timeZone) + #expect(formatter.string(from: interval) == expected) + } + + @Test("Entire year formatting", arguments: [ + (Date("2025-01-01T00:00:00-03:00"), Date("2026-01-01T00:00:00-03:00"), "2025"), + (Date("2024-01-01T00:00:00-03:00"), Date("2025-01-01T00:00:00-03:00"), "2024"), + (Date("2000-01-01T00:00:00-03:00"), Date("2001-01-01T00:00:00-03:00"), "2000") + ]) + func entireYearFormatting(startDate: Date, endDate: Date, expected: String) { + let interval = DateInterval(start: startDate, end: endDate) + let formatter = StatsDateRangeFormatter(locale: locale, timeZone: calendar.timeZone) + #expect(formatter.string(from: interval) == expected) + } + + @Test("Multiple full years formatting", arguments: [ + // Two years + (Date("2023-01-01T00:00:00-03:00"), Date("2025-01-01T00:00:00-03:00"), "2023 – 2024"), + // Three years + (Date("2020-01-01T00:00:00-03:00"), Date("2023-01-01T00:00:00-03:00"), "2020 – 2022"), + // Four years + (Date("2020-01-01T00:00:00-03:00"), Date("2024-01-01T00:00:00-03:00"), "2020 – 2023"), + // Ten years + (Date("2015-01-01T00:00:00-03:00"), Date("2025-01-01T00:00:00-03:00"), "2015 – 2024"), + // Century boundary + (Date("1999-01-01T00:00:00-03:00"), Date("2002-01-01T00:00:00-03:00"), "1999 – 2001") + ]) + func multipleFullYearsFormatting(startDate: Date, endDate: Date, expected: String) { + let interval = DateInterval(start: startDate, end: endDate) + let formatter = StatsDateRangeFormatter(locale: locale, timeZone: calendar.timeZone) + #expect(formatter.string(from: interval) == expected) + } + + // MARK: - Current vs Previous Year Tests + + @Test("Same year as current year formatting") + func sameYearAsCurrentFormatting() { + let formatter = StatsDateRangeFormatter(locale: locale, timeZone: calendar.timeZone) + let now = Date("2025-07-15T10:00:00-03:00") // Fixed date in 2025 + + // Single day in current year - no year shown + let singleDay = DateInterval(start: Date("2025-03-15T00:00:00-03:00"), end: Date("2025-03-16T00:00:00-03:00")) + #expect(formatter.string(from: singleDay, now: now) == "Mar 15") + + // Range within current year - no year shown + let rangeInYear = DateInterval(start: Date("2025-05-01T00:00:00-03:00"), end: Date("2025-05-08T00:00:00-03:00")) + #expect(formatter.string(from: rangeInYear, now: now) == "May 1 – 7") + + // Cross month range in current year - no year shown + let crossMonth = DateInterval(start: Date("2025-06-28T00:00:00-03:00"), end: Date("2025-07-03T00:00:00-03:00")) + #expect(formatter.string(from: crossMonth, now: now) == "Jun 28 – Jul 2") + } + + @Test("Previous year formatting") + func previousYearFormatting() { + let formatter = StatsDateRangeFormatter(locale: locale, timeZone: calendar.timeZone) + let now = Date("2025-07-15T10:00:00-03:00") // Fixed date in 2025 + + // Single day in previous year - year shown + let singleDay = DateInterval(start: Date("2024-03-15T00:00:00-03:00"), end: Date("2024-03-16T00:00:00-03:00")) + #expect(formatter.string(from: singleDay, now: now) == "Mar 15, 2024") + + // Range within previous year - year shown at end + let rangeInYear = DateInterval(start: Date("2024-05-01T00:00:00-03:00"), end: Date("2024-05-08T00:00:00-03:00")) + #expect(formatter.string(from: rangeInYear, now: now) == "May 1 – 7, 2024") + + // Cross month range in previous year - year shown at end + let crossMonth = DateInterval(start: Date("2024-06-28T00:00:00-03:00"), end: Date("2024-07-03T00:00:00-03:00")) + #expect(formatter.string(from: crossMonth, now: now) == "Jun 28 – Jul 2, 2024") + } + + @Test("Cross year formatting with current year") + func crossYearWithCurrentFormatting() { + let formatter = StatsDateRangeFormatter(locale: locale, timeZone: calendar.timeZone) + let now = Date("2025-07-15T10:00:00-03:00") // Fixed date in 2025 + + // Range from previous to current year - both years shown + let crossYear = DateInterval(start: Date("2024-12-28T00:00:00-03:00"), end: Date("2025-01-03T00:00:00-03:00")) + #expect(formatter.string(from: crossYear, now: now) == "Dec 28, 2024 – Jan 2, 2025") + } + + // MARK: - DateRangePreset Integration Tests + + @Test("DateRangePreset formatting - current year", arguments: [ + (DateIntervalPreset.today, "Mar 15"), + (DateIntervalPreset.thisWeek, "Mar 9 – 15"), + (DateIntervalPreset.thisMonth, "Mar 2025"), + (DateIntervalPreset.thisYear, "2025"), + (DateIntervalPreset.last7Days, "Mar 8 – 14"), + (DateIntervalPreset.last30Days, "Feb 13 – Mar 14") + ]) + func dateRangePresetFormattingCurrentYear(preset: DateIntervalPreset, expected: String) { + // Set up a specific date in 2025 + let now = Date("2025-03-15T14:30:00-03:00") + let formatter = StatsDateRangeFormatter(locale: locale, timeZone: calendar.timeZone) + + let interval = calendar.makeDateInterval(for: preset, now: now) + #expect(formatter.string(from: interval, now: now) == expected) + } + + @Test("DateRangePreset formatting - year boundaries") + func dateRangePresetFormattingYearBoundaries() { + // Test date near year boundary - January 5, 2025 + let now = Date("2025-01-05T10:00:00-03:00") + let formatter = StatsDateRangeFormatter(locale: locale, timeZone: calendar.timeZone) + + // Last 30 days crosses year boundary + let last30Days = calendar.makeDateInterval(for: .last30Days, now: now) + #expect(formatter.string(from: last30Days, now: now) == "Dec 6, 2024 – Jan 4, 2025") + } + + @Test("DateRangePreset formatting - custom ranges") + func dateRangePresetFormattingCustomRanges() { + let formatter = StatsDateRangeFormatter(locale: locale, timeZone: calendar.timeZone) + + // Custom single day + let customDay = DateInterval( + start: Date("2025-06-10T00:00:00-03:00"), + end: Date("2025-06-11T00:00:00-03:00") + ) + #expect(formatter.string(from: customDay) == "Jun 10") + + // Custom range in same month + let customRange = DateInterval( + start: Date("2025-06-05T00:00:00-03:00"), + end: Date("2025-06-15T00:00:00-03:00") + ) + #expect(formatter.string(from: customRange) == "Jun 5 – 14") + + // Custom range across months + let customCrossMonth = DateInterval( + start: Date("2025-05-25T00:00:00-03:00"), + end: Date("2025-06-05T00:00:00-03:00") + ) + #expect(formatter.string(from: customCrossMonth) == "May 25 – Jun 4") + } + + // MARK: - Localization Tests + + @Test("Different locales", arguments: [ + ("en_US", Date("2025-01-15T00:00:00-03:00"), Date("2025-01-16T00:00:00-03:00"), "Jan 15"), + ("es_ES", Date("2025-01-15T00:00:00-03:00"), Date("2025-01-16T00:00:00-03:00"), "15 ene"), + ("fr_FR", Date("2025-01-15T00:00:00-03:00"), Date("2025-01-16T00:00:00-03:00"), "15 janv."), + ("de_DE", Date("2025-01-15T00:00:00-03:00"), Date("2025-01-16T00:00:00-03:00"), "15. Jan.") + ]) + func differentLocales(localeId: String, startDate: Date, endDate: Date, expected: String) { + let locale = Locale(identifier: localeId) + let interval = DateInterval(start: startDate, end: endDate) + let formatter = StatsDateRangeFormatter(locale: locale, timeZone: calendar.timeZone) + #expect(formatter.string(from: interval) == expected) + } +} diff --git a/Modules/Tests/JetpackStatsTests/StatsDateRangeTests.swift b/Modules/Tests/JetpackStatsTests/StatsDateRangeTests.swift new file mode 100644 index 000000000000..76e32af10c90 --- /dev/null +++ b/Modules/Tests/JetpackStatsTests/StatsDateRangeTests.swift @@ -0,0 +1,114 @@ +import Testing +import Foundation +@testable import JetpackStats + +@Suite +struct StatsDateRangeTests { + let calendar = Calendar.mock(timeZone: TimeZone(secondsFromGMT: 0)!) + + @Test + func testNavigateToPrevious() { + // GIVEN + let initialRange = StatsDateRange( + interval: DateInterval( + start: Date("2025-01-15T00:00:00Z"), + end: Date("2025-01-16T00:00:00Z") + ), + component: .day, + calendar: calendar + ) + + // WHEN + let previousRange = initialRange.navigate(.backward) + + // THEN + #expect(previousRange.dateInterval.start == Date("2025-01-14T00:00:00Z")) + #expect(previousRange.dateInterval.end == Date("2025-01-15T00:00:00Z")) + #expect(previousRange.calendar == calendar) + #expect(previousRange.component == .day) + } + + @Test + func testNavigateToNext() { + // GIVEN + let initialRange = StatsDateRange( + interval: DateInterval( + start: Date("2025-01-15T00:00:00Z"), + end: Date("2025-01-16T00:00:00Z") + ), + component: .day, + calendar: calendar + ) + + // WHEN + let nextRange = initialRange.navigate(.forward) + + // THEN + #expect(nextRange.dateInterval.start == Date("2025-01-16T00:00:00Z")) + #expect(nextRange.dateInterval.end == Date("2025-01-17T00:00:00Z")) + #expect(nextRange.calendar == calendar) + #expect(nextRange.component == .day) + } + + @Test + func testCalendarIsPreservedInNavigation() { + // GIVEN + var customCalendar = Calendar(identifier: .gregorian) + customCalendar.timeZone = TimeZone(identifier: "America/New_York")! + + let initialRange = StatsDateRange( + interval: DateInterval( + start: Date("2025-01-15T00:00:00Z"), + end: Date("2025-01-16T00:00:00Z") + ), + component: .day, + calendar: customCalendar + ) + + // WHEN + let nextRange = initialRange.navigate(.forward) + + // THEN + #expect(nextRange.calendar.timeZone == customCalendar.timeZone) + } + + @Test + func testAvailableAdjacentPeriods() { + // GIVEN + let initialRange = StatsDateRange( + interval: DateInterval( + start: Date("2020-01-01T00:00:00Z"), + end: Date("2021-01-01T00:00:00Z") + ), + component: .year, + calendar: calendar + ) + + // WHEN - Test backward navigation + let backwardPeriods = initialRange.availableAdjacentPeriods(in: .backward, maxCount: 10) + + // THEN + #expect(backwardPeriods.count == 10) + #expect(backwardPeriods[0].displayText == "2019") + #expect(backwardPeriods[1].displayText == "2018") + #expect(backwardPeriods[2].displayText == "2017") + #expect(backwardPeriods[9].displayText == "2010") + + // Verify ranges are correct + #expect(backwardPeriods[0].range.dateInterval.start == Date("2019-01-01T00:00:00Z")) + #expect(backwardPeriods[0].range.dateInterval.end == Date("2020-01-01T00:00:00Z")) + + // WHEN - Test forward navigation (should be limited by current date) + let forwardPeriods = initialRange.availableAdjacentPeriods(in: .forward, maxCount: 10) + + // THEN - Should have 5 periods available (2021, 2022, 2023, 2024, 2025) + #expect(forwardPeriods.count == 5) + #expect(forwardPeriods[0].displayText == "2021") + #expect(forwardPeriods[1].displayText == "2022") + #expect(forwardPeriods[4].displayText == "2025") + + // Verify all periods have unique IDs + let ids = backwardPeriods.map(\.id) + forwardPeriods.map(\.id) + #expect(Set(ids).count == ids.count) + } +} diff --git a/Modules/Tests/JetpackStatsTests/StatsValueFormatterTests.swift b/Modules/Tests/JetpackStatsTests/StatsValueFormatterTests.swift new file mode 100644 index 000000000000..8d9e777f4bbf --- /dev/null +++ b/Modules/Tests/JetpackStatsTests/StatsValueFormatterTests.swift @@ -0,0 +1,146 @@ +import Testing +@testable import JetpackStats + +struct StatsValueFormatterTests { + + @Test + func formatTimeOnSite() { + let formatter = StatsValueFormatter(metric: .timeOnSite) + + #expect(formatter.format(value: 0) == "0s") + #expect(formatter.format(value: 30) == "30s") + #expect(formatter.format(value: 59) == "59s") + #expect(formatter.format(value: 60) == "1m 0s") + #expect(formatter.format(value: 90) == "1m 30s") + #expect(formatter.format(value: 120) == "2m 0s") + #expect(formatter.format(value: 3661) == "61m 1s") + } + + @Test + func formatTimeOnSiteCompact() { + let formatter = StatsValueFormatter(metric: .timeOnSite) + + #expect(formatter.format(value: 0, context: .compact) == "0s") + #expect(formatter.format(value: 30, context: .compact) == "30s") + #expect(formatter.format(value: 59, context: .compact) == "59s") + #expect(formatter.format(value: 60, context: .compact) == "1m") + #expect(formatter.format(value: 90, context: .compact) == "1m") + #expect(formatter.format(value: 120, context: .compact) == "2m") + #expect(formatter.format(value: 3661, context: .compact) == "61m") + } + + @Test + func formatBounceRate() { + let formatter = StatsValueFormatter(metric: .bounceRate) + + #expect(formatter.format(value: 0) == "0%") + #expect(formatter.format(value: 25) == "25%") + #expect(formatter.format(value: 50) == "50%") + #expect(formatter.format(value: 75) == "75%") + #expect(formatter.format(value: 100) == "100%") + } + + @Test + func formatBounceRateCompact() { + let formatter = StatsValueFormatter(metric: .bounceRate) + + #expect(formatter.format(value: 0, context: .compact) == "0%") + #expect(formatter.format(value: 25, context: .compact) == "25%") + #expect(formatter.format(value: 50, context: .compact) == "50%") + #expect(formatter.format(value: 75, context: .compact) == "75%") + #expect(formatter.format(value: 100, context: .compact) == "100%") + } + + @Test + func formatRegularMetrics() { + let metrics: [SiteMetric] = [.views, .visitors, .likes, .comments] + + for metric in metrics { + let formatter = StatsValueFormatter(metric: metric) + + #expect(formatter.format(value: 0) == "0") + #expect(formatter.format(value: 123) == "123") + #expect(formatter.format(value: 1234) == "1,234") + #expect(formatter.format(value: 9999) == "9,999") + #expect(formatter.format(value: 10000) == "10K") + #expect(formatter.format(value: 15789) == "16K") + #expect(formatter.format(value: 999999) == "1M") + #expect(formatter.format(value: 1000000) == "1M") + #expect(formatter.format(value: 1500000) == "1.5M") + } + } + + @Test + func formatRegularMetricsCompact() { + let metrics: [SiteMetric] = [.views, .visitors, .likes, .comments] + + for metric in metrics { + let formatter = StatsValueFormatter(metric: metric) + + #expect(formatter.format(value: 0, context: .compact) == "0") + #expect(formatter.format(value: 123, context: .compact) == "123") + #expect(formatter.format(value: 1234, context: .compact) == "1.2K") + #expect(formatter.format(value: 9999, context: .compact) == "10K") + #expect(formatter.format(value: 10000, context: .compact) == "10K") + #expect(formatter.format(value: 15789, context: .compact) == "16K") + #expect(formatter.format(value: 999999, context: .compact) == "1M") + #expect(formatter.format(value: 1000000, context: .compact) == "1M") + #expect(formatter.format(value: 1500000, context: .compact) == "1.5M") + } + } + + @Test + func formatNumberStatic() { + #expect(StatsValueFormatter.formatNumber(0) == "0") + #expect(StatsValueFormatter.formatNumber(123) == "123") + #expect(StatsValueFormatter.formatNumber(1234) == "1.2K") + #expect(StatsValueFormatter.formatNumber(9999) == "10K") + #expect(StatsValueFormatter.formatNumber(10000) == "10K") + #expect(StatsValueFormatter.formatNumber(15789) == "16K") + #expect(StatsValueFormatter.formatNumber(999999) == "1M") + #expect(StatsValueFormatter.formatNumber(1000000) == "1M") + #expect(StatsValueFormatter.formatNumber(1500000) == "1.5M") + #expect(StatsValueFormatter.formatNumber(-1234) == "-1.2K") + #expect(StatsValueFormatter.formatNumber(-10000) == "-10K") + } + + @Test + func formatNumberStaticOnlyLarge() { + #expect(StatsValueFormatter.formatNumber(0, onlyLarge: true) == "0") + #expect(StatsValueFormatter.formatNumber(123, onlyLarge: true) == "123") + #expect(StatsValueFormatter.formatNumber(1234, onlyLarge: true) == "1,234") + #expect(StatsValueFormatter.formatNumber(9999, onlyLarge: true) == "9,999") + #expect(StatsValueFormatter.formatNumber(10000, onlyLarge: true) == "10K") + #expect(StatsValueFormatter.formatNumber(15789, onlyLarge: true) == "16K") + #expect(StatsValueFormatter.formatNumber(999999, onlyLarge: true) == "1M") + #expect(StatsValueFormatter.formatNumber(1000000, onlyLarge: true) == "1M") + #expect(StatsValueFormatter.formatNumber(1500000, onlyLarge: true) == "1.5M") + #expect(StatsValueFormatter.formatNumber(-9999, onlyLarge: true) == "-9,999") + #expect(StatsValueFormatter.formatNumber(-10000, onlyLarge: true) == "-10K") + } + + @Test + func percentageChange() { + let formatter = StatsValueFormatter(metric: .views) + + #expect(formatter.percentageChange(current: 100, previous: 100) == 0.0) + #expect(formatter.percentageChange(current: 150, previous: 100) == 0.5) + #expect(formatter.percentageChange(current: 200, previous: 100) == 1.0) + #expect(formatter.percentageChange(current: 50, previous: 100) == -0.5) + #expect(formatter.percentageChange(current: 0, previous: 100) == -1.0) + #expect(formatter.percentageChange(current: 100, previous: 0) == 0.0) + #expect(formatter.percentageChange(current: 0, previous: 0) == 0.0) + } + + @Test + func percentageChangeEdgeCases() { + let formatter = StatsValueFormatter(metric: .views) + + #expect(formatter.percentageChange(current: 75, previous: 50) == 0.5) + #expect(formatter.percentageChange(current: 25, previous: 50) == -0.5) + #expect(formatter.percentageChange(current: 110, previous: 100) == 0.1) + #expect(formatter.percentageChange(current: 90, previous: 100) == -0.1) + #expect(formatter.percentageChange(current: 1, previous: 10) == -0.9) + #expect(formatter.percentageChange(current: 10, previous: 1) == 9.0) + } +} diff --git a/Modules/Tests/JetpackStatsTests/TestHelpers.swift b/Modules/Tests/JetpackStatsTests/TestHelpers.swift new file mode 100644 index 000000000000..ef153fc22308 --- /dev/null +++ b/Modules/Tests/JetpackStatsTests/TestHelpers.swift @@ -0,0 +1,31 @@ +import Foundation +@testable import JetpackStats + +extension TimeZone { + /// For simplicity, returns a timezone with a -3 hours offset from GMT. + static let eastern = TimeZone(secondsFromGMT: -10_800)! +} + +extension Calendar { + /// Returns a mock Calendar with the given time zone. By default, uses + /// ``TimeZone/est``. + static func mock(timeZone: TimeZone = .eastern) -> Calendar { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = timeZone + calendar.locale = Locale(identifier: "en_US") + return calendar + } +} + +extension Date { + /// Creates a Date from an ISO 8601 string. + /// Supports formats like "2025-01-15T14:30:00Z" or "2025-01-15T14:30:00-03:00" + init(_ isoString: String) { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + guard let date = formatter.date(from: isoString) else { + fatalError("Invalid date string: \(isoString)") + } + self = date + } +} diff --git a/Modules/Tests/JetpackStatsTests/TrendViewModelTests.swift b/Modules/Tests/JetpackStatsTests/TrendViewModelTests.swift new file mode 100644 index 000000000000..03b7b3a7072c --- /dev/null +++ b/Modules/Tests/JetpackStatsTests/TrendViewModelTests.swift @@ -0,0 +1,157 @@ +import Foundation +import Testing +@testable import JetpackStats + +@Suite +struct TrendViewModelTests { + + @Test("Sign for value changes", arguments: [ + (100, 50, "+"), + (50, 100, "-"), + (100, 100, "+") + ]) + func testSign(current: Int, previous: Int, expectedSign: String) { + // GIVEN + let viewModel = TrendViewModel(currentValue: current, previousValue: previous, metric: .views) + + // WHEN + let sign = viewModel.sign + + // THEN + #expect(sign == expectedSign) + } + + @Test("Sentiment for metrics where higher is better", arguments: [ + (SiteMetric.views, 100, 50, TrendSentiment.positive), + (.views, 50, 100, .negative), + (.views, 100, 100, .neutral), + (.visitors, 200, 100, .positive), + (.visitors, 100, 200, .negative) + ]) + func testSentimentHigherIsBetter(metric: SiteMetric, current: Int, previous: Int, expectedSentiment: TrendSentiment) { + // GIVEN + let viewModel = TrendViewModel(currentValue: current, previousValue: previous, metric: metric) + + // WHEN + let sentiment = viewModel.sentiment + + // THEN + #expect(sentiment == expectedSentiment) + } + + @Test("Sentiment for metrics where lower is better", arguments: [ + (SiteMetric.bounceRate, 30, 40, TrendSentiment.positive), + (.bounceRate, 40, 30, .negative), + (.bounceRate, 30, 30, .neutral) + ]) + func testSentimentLowerIsBetter(metric: SiteMetric, current: Int, previous: Int, expectedSentiment: TrendSentiment) { + // GIVEN + let viewModel = TrendViewModel(currentValue: current, previousValue: previous, metric: metric) + + // WHEN + let sentiment = viewModel.sentiment + + // THEN + #expect(sentiment == expectedSentiment) + } + + @Test("Percentage calculation", arguments: [ + (150, 100, 0.5), // 50% increase + (50, 100, 0.5), // 50% decrease + (200, 100, 1.0), // 100% increase + (0, 100, 1.0), // 100% decrease + (100, 100, 0.0) // No change + ]) + func testPercentageCalculation(current: Int, previous: Int, expected: Double) { + // GIVEN + let viewModel = TrendViewModel(currentValue: current, previousValue: previous, metric: .views) + + // WHEN + let percentage = viewModel.percentage + + // THEN + #expect(percentage == expected) + } + + @Test("Percentage calculation with zero divisor", arguments: [ + (100, 0), // Divide by zero + (0, 0) // Both zero + ]) + func testPercentageCalculationWithZeroDivisor(current: Int, previous: Int) { + // GIVEN + let viewModel = TrendViewModel(currentValue: current, previousValue: previous, metric: .views) + + // WHEN + let percentage = viewModel.percentage + + // THEN + #expect(percentage == nil) + } + + @Test("Percentage with negative values") + func testPercentageWithNegativeValues() { + // GIVEN/WHEN + let viewModel = TrendViewModel(currentValue: -50, previousValue: -100, metric: .views) + + // THEN + #expect(viewModel.percentage == 0.5) + } + + @Test("Formatted change string", arguments: [ + (1500, 1000, SiteMetric.views, "+500"), + (1000, 1500, .views, "-500"), + (1000, 1000, .views, "+0"), + (5000, 0, .views, "+5K"), + (0, 5000, .views, "-5K") + ]) + func testFormattedChange(current: Int, previous: Int, metric: SiteMetric, contains: String) { + // GIVEN + let viewModel = TrendViewModel( + currentValue: current, + previousValue: previous, + metric: metric + ) + + // WHEN + let formattedChange = viewModel.formattedChange + + // THEN + #expect(formattedChange.contains(contains)) + } + + @Test("Formatted percentage string", arguments: [ + (150, 100, "50%"), + (175, 100, "75%"), + (100, 100, "0%"), + (125, 100, "25%"), + (100, 0, "∞") + ]) + func testFormattedPercentage(current: Int, previous: Int, expected: String) { + // GIVEN + let viewModel = TrendViewModel(currentValue: current, previousValue: previous, metric: .views) + + // WHEN + let formatted = viewModel.formattedPercentage + + // THEN + #expect(formatted == expected) + } + + @Test("Edge cases with extreme values") + func testEdgeCasesWithExtremeValues() { + // GIVEN + let maxInt = Int.max + let minInt = Int.min + + // WHEN + let viewModel1 = TrendViewModel(currentValue: maxInt, previousValue: 0, metric: .views) + let viewModel2 = TrendViewModel(currentValue: 0, previousValue: minInt, metric: .views) + let viewModel3 = TrendViewModel(currentValue: maxInt, previousValue: maxInt, metric: .views) + + // THEN + #expect(viewModel1.sign == "+") + #expect(viewModel2.sign == "+") + #expect(viewModel3.sentiment == .neutral) + #expect(viewModel3.percentage == 0.0) + } +} diff --git a/Sources/Miniature/ContentView.swift b/Sources/Miniature/ContentView.swift index ed1675b112f4..ab0264f11fbd 100644 --- a/Sources/Miniature/ContentView.swift +++ b/Sources/Miniature/ContentView.swift @@ -3,6 +3,7 @@ import WordPressUI import WordPressData import WordPressShared import WordPressKit +import JetpackStats struct ContentView: View { var body: some View { diff --git a/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift b/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift index d6d2501c6675..dd3ae870d0a3 100644 --- a/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift +++ b/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift @@ -26,6 +26,7 @@ public enum FeatureFlag: Int, CaseIterable { case pluginManagementOverhaul case nativeJetpackConnection case newsletterSubscribers + case newStats /// Returns a boolean indicating if the feature is enabled. /// @@ -82,6 +83,8 @@ public enum FeatureFlag: Int, CaseIterable { return BuildConfiguration.current == .debug case .newsletterSubscribers: return true + case .newStats: + return BuildConfiguration.current == .debug } } @@ -125,6 +128,7 @@ extension FeatureFlag { case .readerGutenbergCommentComposer: "Gutenberg Comment Composer" case .nativeJetpackConnection: "Native Jetpack Connection" case .newsletterSubscribers: "Newsletter Subscribers" + case .newStats: "New Stats" } } } diff --git a/WordPress/Classes/Utility/ContentCoordinator.swift b/WordPress/Classes/Utility/ContentCoordinator.swift index 9ad8bd8145bf..18c2dbf84d98 100644 --- a/WordPress/Classes/Utility/ContentCoordinator.swift +++ b/WordPress/Classes/Utility/ContentCoordinator.swift @@ -65,9 +65,15 @@ struct DefaultContentCoordinator: ContentCoordinator { setTimePeriodForStatsURLIfPossible(url) } - let statsViewController = StatsViewController() - statsViewController.blog = blog - controller?.navigationController?.pushViewController(statsViewController, animated: true) + if FeatureFlag.newStats.enabled { + let statsVC = StatsHostingViewController(blog: blog) + statsVC.hidesBottomBarWhenPushed = true + controller?.navigationController?.pushViewController(statsVC, animated: true) + } else { + let statsViewController = StatsViewController() + statsViewController.blog = blog + controller?.navigationController?.pushViewController(statsViewController, animated: true) + } } private func setTimePeriodForStatsURLIfPossible(_ url: URL) { diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift index 495a7a0f489f..34dae7c27852 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift @@ -266,11 +266,19 @@ extension BlogDetailsViewController { return MovedToJetpackViewController(source: .stats) } - let statsVC = StatsViewController() - statsVC.blog = blog - statsVC.hidesBottomBarWhenPushed = true - statsVC.navigationItem.largeTitleDisplayMode = .never - return statsVC + // Use new stats if feature flag is enabled + if FeatureFlag.newStats.enabled { + let statsVC = StatsHostingViewController(blog: blog) + statsVC.hidesBottomBarWhenPushed = true + statsVC.navigationItem.largeTitleDisplayMode = .never + return statsVC + } else { + let statsVC = StatsViewController() + statsVC.blog = blog + statsVC.hidesBottomBarWhenPushed = true + statsVC.navigationItem.largeTitleDisplayMode = .never + return statsVC + } } @objc(showDomainsFromSource:) diff --git a/WordPress/Classes/ViewRelated/Me/App Settings/ExperimentalFeaturesDataProvider.swift b/WordPress/Classes/ViewRelated/Me/App Settings/ExperimentalFeaturesDataProvider.swift index b99bf977627a..8df986e2f6da 100644 --- a/WordPress/Classes/ViewRelated/Me/App Settings/ExperimentalFeaturesDataProvider.swift +++ b/WordPress/Classes/ViewRelated/Me/App Settings/ExperimentalFeaturesDataProvider.swift @@ -8,6 +8,7 @@ class ExperimentalFeaturesDataProvider: ExperimentalFeaturesViewModel.DataProvid FeatureFlag.authenticateUsingApplicationPassword, RemoteFeatureFlag.newGutenberg, FeatureFlag.newGutenbergThemeStyles, + FeatureFlag.newStats, ] private let flagStore = FeatureFlagOverrideStore() diff --git a/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift b/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift new file mode 100644 index 000000000000..04061639f833 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift @@ -0,0 +1,164 @@ +import UIKit +import SwiftUI +import JetpackStats +import WordPressKit + +/// A UIViewController wrapper for the new SwiftUI StatsMainView +class StatsHostingViewController: UIViewController { + + let blog: Blog + var dismissBlock: (() -> Void)? + + private var isUsingMockService = false + private var hostingController: UIHostingController? + + init(blog: Blog) { + self.blog = blog + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + title = NSLocalizedString("Stats", comment: "Stats screen title") + setupStatsView() + setupNavigationBar() + } + + private func setupStatsView() { + guard let siteID = blog.dotComID?.intValue, + let api = blog.account?.wordPressComRestApi else { + showErrorView() + return + } + + let siteTimezone = blog.timeZone ?? TimeZone.current + + // Create the context + let context: StatsContext + if isUsingMockService { + // For mock service, we need to use the internal initializer + // Since we can't access it directly, we'll use the demo context + context = StatsContext.demo + } else { + // For real service, use the public initializer + context = StatsContext(timeZone: siteTimezone, siteID: siteID, api: api) + } + + // Create the SwiftUI view + let statsView = StatsMainView(context: context) + let hostingController = UIHostingController(rootView: AnyView(statsView)) + + // Add as child view controller + addChild(hostingController) + view.addSubview(hostingController.view) + hostingController.view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + hostingController.view.topAnchor.constraint(equalTo: view.topAnchor), + hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + hostingController.didMove(toParent: self) + + self.hostingController = hostingController + } + + private func setupNavigationBar() { + // Add menu button with ellipsis + let menuButton = UIBarButtonItem( + image: UIImage(systemName: "ellipsis"), + menu: createMenu() + ) + navigationItem.rightBarButtonItem = menuButton + } + + private func createMenu() -> UIMenu { + let toggleDataSource = UIAction( + title: isUsingMockService ? "Use Real Data" : "Use Mock Data", + image: UIImage(systemName: "arrow.triangle.2.circlepath") + ) { [weak self] _ in + self?.toggleServiceType() + } + + // We can add more menu items here later + + return UIMenu(children: [toggleDataSource]) + } + + private func updateNavigationMenu() { + navigationItem.rightBarButtonItem?.menu = createMenu() + } + + private func toggleServiceType() { + isUsingMockService.toggle() + + // Remove existing hosting controller + hostingController?.willMove(toParent: nil) + hostingController?.view.removeFromSuperview() + hostingController?.removeFromParent() + hostingController = nil + + // Recreate with new service + setupStatsView() + + // Update menu + updateNavigationMenu() + + // Show toast indicating the change + let message = isUsingMockService ? "Using mock data" : "Using real data" + showToast(message: message) + } + + private func showToast(message: String) { + let toast = UILabel() + toast.text = message + toast.backgroundColor = UIColor.black.withAlphaComponent(0.7) + toast.textColor = .white + toast.textAlignment = .center + toast.layer.cornerRadius = 8 + toast.clipsToBounds = true + toast.translatesAutoresizingMaskIntoConstraints = false + + view.addSubview(toast) + NSLayoutConstraint.activate([ + toast.centerXAnchor.constraint(equalTo: view.centerXAnchor), + toast.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -20), + toast.heightAnchor.constraint(equalToConstant: 40), + toast.widthAnchor.constraint(equalToConstant: 200) + ]) + + UIView.animate(withDuration: 0.3, delay: 2.0, options: .curveEaseOut) { + toast.alpha = 0 + } completion: { _ in + toast.removeFromSuperview() + } + } + + private func showErrorView() { + let errorLabel = UILabel() + errorLabel.text = "Unable to load stats" + errorLabel.textAlignment = .center + errorLabel.translatesAutoresizingMaskIntoConstraints = false + + view.addSubview(errorLabel) + NSLayoutConstraint.activate([ + errorLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), + errorLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor) + ]) + } +} + +// MARK: - Presentation +extension StatsHostingViewController { + static func show(for blog: Blog, from viewController: UIViewController) { + let statsVC = StatsHostingViewController(blog: blog) + + let navController = UINavigationController(rootViewController: statsVC) + viewController.present(navController, animated: true) + } +} diff --git a/WordPress/Classes/ViewRelated/Stats/StatsViewController.swift b/WordPress/Classes/ViewRelated/Stats/StatsViewController.swift index 387511cf450c..b36e35182459 100644 --- a/WordPress/Classes/ViewRelated/Stats/StatsViewController.swift +++ b/WordPress/Classes/ViewRelated/Stats/StatsViewController.swift @@ -15,4 +15,15 @@ extension StatsViewController { controller.view.pinEdges() } + /// Shows the stats view controller for the given blog, using the new stats UI if the feature flag is enabled + @objc public static func show(for blog: Blog, from viewController: UIViewController) { + if FeatureFlag.newStats.enabled { + let statsVC = StatsHostingViewController(blog: blog) + statsVC.hidesBottomBarWhenPushed = true + viewController.navigationController?.pushViewController(statsVC, animated: true) + } else { + // Use the existing Objective-C method + show(for: blog, from: viewController) + } + } } From 29d688df089ee001120390e108d1b62247bfb85a Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Mon, 21 Jul 2025 16:14:03 -0400 Subject: [PATCH 002/349] Integrate JetpackStats --- Modules/Package.resolved | 4 +- Modules/Package.swift | 2 +- .../JetpackStats/Cards/CardViewModel.swift | 12 + .../JetpackStats/Cards/ChartCard.swift | 128 ++++---- .../Cards/ChartCardViewModel.swift | 38 ++- .../Cards/RealtimeTopListCard.swift | 12 +- .../JetpackStats/Cards/TopListCard.swift | 167 ++++------ .../Cards/TopListCardViewModel.swift | 80 +++-- .../JetpackStats/Charts/BarChartView.swift | 1 + .../JetpackStats/Charts/ChartData.swift | 14 +- .../JetpackStats/Charts/LineChartView.swift | 1 + Modules/Sources/JetpackStats/Constants.swift | 1 + .../Screens/ChartDataListView.swift | 2 + .../JetpackStats/Screens/TrafficTabView.swift | 79 ++--- .../Services/Data/DataPoint.swift | 4 +- .../Services/Data/SiteMetric.swift | 8 +- .../Services/Data/SiteMetricsData.swift | 13 + .../Services/Data/SiteMetricsSet.swift | 38 +++ .../Services/Data/SiteStatsData.swift | 5 - .../Services/Data/TopListChartData.swift | 76 +---- .../Services/Data/TopListData.swift | 33 +- .../Services/Data/TopListItemType.swift | 5 +- .../Services/Mocks/MockStatsService.swift | 26 +- .../JetpackStats/Services/StatsService.swift | 284 +++++++++++------- .../Services/StatsServiceProtocol.swift | 7 +- .../Sources/JetpackStats/StatsContext.swift | 4 +- Modules/Sources/JetpackStats/Strings.swift | 8 + .../Utilities/AppLocalizedString.swift | 28 -- .../Formatters/StatsDateFormatter.swift | 104 +++++-- .../Utilities/TrendViewModel.swift | 7 +- .../Views/ChartValueTooltipView.swift | 6 +- .../JetpackStats/Views/SimpleErrorView.swift | 12 +- .../JetpackStats/Views/StatsTabBar.swift | 1 + .../TopList/Rows/TopListPostRowView.swift | 19 +- .../Views/TopList/TopListItemView.swift | 2 +- .../Views/TopList/TopListItemsView.swift | 70 +---- .../Views/TopList/TopListMetricsView.swift | 2 +- .../MockStatsServiceTests.swift | 2 +- .../StatsDateFormatterTests.swift | 36 ++- .../Classes/Utility/ContentCoordinator.swift | 11 +- .../DashboardQuickActionsCardCell.swift | 3 +- .../BlogDetailsViewController+Swift.swift | 15 +- .../Stats/Charts/Charts+Support.swift | 4 + .../Stats/Helpers/StatsDataHelper.swift | 5 + .../Stats/Helpers/StatsPeriodHelper.swift | 9 + .../SiteStatsTableHeaderView.swift | 2 + .../Stats/StatsHostingViewController.swift | 15 + .../ViewRelated/Stats/StatsViewController.h | 2 - .../ViewRelated/Stats/StatsViewController.m | 9 - .../Stats/StatsViewController.swift | 12 - .../Traffic/StatsTrafficDatePickerView.swift | 2 + .../StatsTrafficDatePickerViewModel.swift | 4 + 52 files changed, 790 insertions(+), 644 deletions(-) create mode 100644 Modules/Sources/JetpackStats/Cards/CardViewModel.swift create mode 100644 Modules/Sources/JetpackStats/Services/Data/SiteMetricsData.swift create mode 100644 Modules/Sources/JetpackStats/Services/Data/SiteMetricsSet.swift delete mode 100644 Modules/Sources/JetpackStats/Services/Data/SiteStatsData.swift delete mode 100644 Modules/Sources/JetpackStats/Utilities/AppLocalizedString.swift diff --git a/Modules/Package.resolved b/Modules/Package.resolved index c5b8f2ebadc6..d91bdbcbdda5 100644 --- a/Modules/Package.resolved +++ b/Modules/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "2d8453fbfe8c6681b647319f80bdc7e423722b43466750559095392aee0a8b16", + "originHash" : "53f781e3200e2cdd0d592e9fd8be54771b128e996dfb0d5d650ea5dd3688fa63", "pins" : [ { "identity" : "alamofire", @@ -381,7 +381,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/wordpress-mobile/WordPressKit-iOS", "state" : { - "revision" : "ca75e34cee173868ca8e34348f8ff093e6ccf3b3" + "revision" : "392d2d34356ba5ea7ea2e01b32f55cc261fdf769" } }, { diff --git a/Modules/Package.swift b/Modules/Package.swift index 58e5f3d88795..de364a2e83ea 100644 --- a/Modules/Package.swift +++ b/Modules/Package.swift @@ -50,7 +50,7 @@ let package = Package( .package(url: "https://github.com/wordpress-mobile/NSURL-IDN", revision: "b34794c9a3f32312e1593d4a3d120572afa0d010"), .package( url: "https://github.com/wordpress-mobile/WordPressKit-iOS", - revision: "ca75e34cee173868ca8e34348f8ff093e6ccf3b3" // see wpios-edition branch + revision: "392d2d34356ba5ea7ea2e01b32f55cc261fdf769" // see wpios-edition branch ), .package(url: "https://github.com/zendesk/support_sdk_ios", from: "8.0.3"), // We can't use wordpress-rs branches nor commits here. Only tags work. diff --git a/Modules/Sources/JetpackStats/Cards/CardViewModel.swift b/Modules/Sources/JetpackStats/Cards/CardViewModel.swift new file mode 100644 index 000000000000..310e2a169bfc --- /dev/null +++ b/Modules/Sources/JetpackStats/Cards/CardViewModel.swift @@ -0,0 +1,12 @@ +import Foundation + +@MainActor +protocol TrafficCardViewModel: AnyObject { + var dateRange: StatsDateRange { get set } +} + +extension TrafficCardViewModel { + nonisolated var id: ObjectIdentifier { + ObjectIdentifier(self) + } +} diff --git a/Modules/Sources/JetpackStats/Cards/ChartCard.swift b/Modules/Sources/JetpackStats/Cards/ChartCard.swift index 8a69dd3e87df..3850f42a4745 100644 --- a/Modules/Sources/JetpackStats/Cards/ChartCard.swift +++ b/Modules/Sources/JetpackStats/Cards/ChartCard.swift @@ -2,10 +2,10 @@ import SwiftUI import Charts struct ChartCard: View { - let metrics: [SiteMetric] - let dateRange: StatsDateRange + @ObservedObject private var viewModel: ChartCardViewModel - @StateObject private var viewModel: ChartCardViewModel + private var dateRange: StatsDateRange { viewModel.dateRange } + private var metrics: [SiteMetric] { viewModel.metrics } @State private var selectedMetric: SiteMetric @State private var selectedChartType: ChartType = .line @@ -13,17 +13,10 @@ struct ChartCard: View { @ScaledMetric(relativeTo: .body) private var chartHeight = 180 - init(metrics: [SiteMetric], dateRange: StatsDateRange, service: any StatsServiceProtocol) { - self.metrics = metrics - self.dateRange = dateRange + init(viewModel: ChartCardViewModel) { + self.viewModel = viewModel - assert(metrics.count > 0) - self._selectedMetric = .init(initialValue: metrics.first ?? .views) - - let viewModel = ChartCardViewModel(metrics: metrics, service: service) - self._viewModel = StateObject(wrappedValue: viewModel) - - viewModel.loadData(for: dateRange) + self._selectedMetric = .init(initialValue: viewModel.metrics.first ?? .views) } var body: some View { @@ -40,15 +33,14 @@ struct ChartCard: View { footerView } } - .redacted(reason: viewModel.isFirstLoad ? .placeholder : []) + .onAppear { + viewModel.onAppear() + } .overlay(alignment: .topTrailing) { moreMenu } .grayscale(viewModel.isStale ? 1 : 0) .animation(.smooth, value: viewModel.isStale) - .onChange(of: dateRange) { newRange in - viewModel.loadData(for: newRange) - } } private func headerView(for metric: SiteMetric) -> some View { @@ -60,21 +52,27 @@ struct ChartCard: View { @ViewBuilder private var contentView: some View { - Group { + VStack(spacing: 14) { + // Showing currently selected (not loaded period) by design + ChartLegendView( + metric: selectedMetric, + currentPeriod: dateRange.dateInterval, + previousPeriod: dateRange.effectiveComparisonInterval + ) + .frame(maxWidth: .infinity, alignment: .leading) + if viewModel.isFirstLoad { - mainChartView(metric: selectedMetric, data: mockChartData) - } else if let chartData = viewModel.chartData[selectedMetric] { - mainChartView(metric: selectedMetric, data: chartData) - } else if let error = viewModel.loadingError { mainChartView(metric: selectedMetric, data: mockChartData) .redacted(reason: .placeholder) - .grayscale(1) - .opacity(0.66) - .overlay { - SimpleErrorView(error: error) - .background(Color(.systemBackground).opacity(0.9)) - .padding(-2) // Wasn't covering the chart well - } + .opacity(0.33) + } else if let data = viewModel.chartData[selectedMetric] { + if data.isEmpty, data.granularity == .hour { + loadingErrorView(with: Strings.Chart.hourlyDataUnavailable) + } else { + mainChartView(metric: selectedMetric, data: data) + } + } else { + loadingErrorView(with: viewModel.loadingError?.localizedDescription ?? Strings.Errors.generic) } } .animation(.spring, value: selectedMetric) @@ -86,6 +84,17 @@ struct ChartCard: View { data: viewModel.isFirstLoad ? viewModel.placeholderTabViewData : viewModel.tabViewData, selectedMetric: $selectedMetric ) + .redacted(reason: viewModel.isFirstLoad ? .placeholder : []) + } + + private func loadingErrorView(with message: String) -> some View { + mainChartView(metric: selectedMetric, data: mockChartData) + .redacted(reason: .placeholder) + .grayscale(1) + .opacity(0.1) + .overlay { + SimpleErrorView(message: message) + } } private var mockChartData: ChartData { @@ -117,17 +126,6 @@ struct ChartCard: View { @ViewBuilder private var moreMenuContent: some View { - Section { - ControlGroup { - ForEach(ChartType.allCases, id: \.self) { type in - Button { - selectedChartType = type - } label: { - Label(type.localizedTitle, systemImage: type.systemImage) - } - } - } - } Section { Button { // Not implemented @@ -140,6 +138,17 @@ struct ChartCard: View { Label(Strings.Chart.showData, systemImage: "tablecells") } } + Section { + ControlGroup { + ForEach(ChartType.allCases, id: \.self) { type in + Button { + selectedChartType = type + } label: { + Label(type.localizedTitle, systemImage: type.systemImage) + } + } + } + } } // MARK: - Chart View @@ -147,24 +156,12 @@ struct ChartCard: View { @ViewBuilder private func mainChartView(metric: SiteMetric, data: ChartData) -> some View { VStack(alignment: .leading, spacing: 8) { - // Showing currently selected (not loaded period) by design - ChartLegendView( - metric: metric, - currentPeriod: dateRange.dateInterval, - previousPeriod: dateRange.effectiveComparisonInterval - ) - .unredacted() - .padding(.bottom, 6) - .padding(.trailing, 20) - ChartValuesSummaryView( trend: TrendViewModel.make(data, context: .regular), style: metrics.count > 1 ? .compact : .standard ) - chartContentView(data: data) .frame(height: chartHeight) - .opacity(viewModel.isFirstLoad ? 0.33 : 1) .transition(.push(from: .trailing).combined(with: .opacity).combined(with: .scale)) } } @@ -201,22 +198,23 @@ private enum ChartType: String, CaseIterable { // MARK: - Preview +private struct ChartCardPreview: View { + @StateObject var viewModel = ChartCardViewModel( + metrics: [.views, .visitors, .likes, .comments], + dateRange: Calendar.demo.makeDateRange(for: .today), + service: MockStatsService() + ) + + var body: some View { + ChartCard(viewModel: viewModel) + .cardStyle() + } +} + #Preview { ScrollView { VStack(spacing: 20) { - ChartCard( - metrics: [.views, .visitors, .likes, .comments], - dateRange: Calendar.demo.makeDateRange(for: .last7Days), - service: MockStatsService() - ) - .cardStyle() - - ChartCard( - metrics: [.timeOnSite, .bounceRate], - dateRange: Calendar.demo.makeDateRange(for: .last30Days), - service: MockStatsService() - ) - .cardStyle() + ChartCardPreview() } .padding(.vertical) } diff --git a/Modules/Sources/JetpackStats/Cards/ChartCardViewModel.swift b/Modules/Sources/JetpackStats/Cards/ChartCardViewModel.swift index b81de65d8a6c..27cff438ed90 100644 --- a/Modules/Sources/JetpackStats/Cards/ChartCardViewModel.swift +++ b/Modules/Sources/JetpackStats/Cards/ChartCardViewModel.swift @@ -1,27 +1,42 @@ import SwiftUI @MainActor -final class ChartCardViewModel: ObservableObject { - @Published var chartData: [SiteMetric: ChartData] = [:] - @Published var isLoading = true - @Published var loadingError: Error? - @Published var isStale = false +final class ChartCardViewModel: ObservableObject, TrafficCardViewModel { + let metrics: [SiteMetric] + + @Published private(set) var chartData: [SiteMetric: ChartData] = [:] + @Published private(set) var isLoading = true + @Published private(set) var loadingError: Error? + @Published private(set) var isStale = false + + var dateRange: StatsDateRange { + didSet { + loadData(for: dateRange) + } + } - private let metrics: [SiteMetric] private let service: any StatsServiceProtocol private var loadingTask: Task? private var loadRequestCount = 0 private var staleTimer: Task? + private var isFirstAppear = true var isFirstLoad: Bool { isLoading && chartData.isEmpty } - init(metrics: [SiteMetric], service: any StatsServiceProtocol) { + init(metrics: [SiteMetric], dateRange: StatsDateRange, service: any StatsServiceProtocol) { self.metrics = metrics + self.dateRange = dateRange self.service = service } - func loadData(for dateRange: StatsDateRange) { + func onAppear() { + guard isFirstAppear else { return } + isFirstAppear = false + loadData(for: dateRange) + } + + private func loadData(for dateRange: StatsDateRange) { loadingTask?.cancel() staleTimer?.cancel() @@ -33,7 +48,7 @@ final class ChartCardViewModel: ObservableObject { // no response in more than T seconds. if !chartData.isEmpty { staleTimer = Task { [weak self] in - try? await Task.sleep(for: .seconds(1)) + try? await Task.sleep(for: .seconds(2)) guard !Task.isCancelled else { return } self?.isStale = true } @@ -100,7 +115,8 @@ final class ChartCardViewModel: ObservableObject { for (metric, dataPoints) in currentResponse.metrics { let previousDataPoints = previousResponse.metrics[metric] ?? [] - // Map previous data to align with current period dates + // Map previous data to align with current period dates so they + // are displayed on the same timeline on the charts. let mappedPreviousDataPoints = DataPoint.mapDataPoints( previousDataPoints, from: dateRange.effectiveComparisonInterval, @@ -112,7 +128,9 @@ final class ChartCardViewModel: ObservableObject { output[metric] = ChartData( metric: metric, granularity: granularity, + currentTotal: currentResponse.total[metric] ?? 0, currentData: dataPoints, + previousTotal: previousResponse.total[metric] ?? 0, previousData: previousDataPoints, mappedPreviousData: mappedPreviousDataPoints ) diff --git a/Modules/Sources/JetpackStats/Cards/RealtimeTopListCard.swift b/Modules/Sources/JetpackStats/Cards/RealtimeTopListCard.swift index c84a9689f971..66a047c2ad99 100644 --- a/Modules/Sources/JetpackStats/Cards/RealtimeTopListCard.swift +++ b/Modules/Sources/JetpackStats/Cards/RealtimeTopListCard.swift @@ -82,9 +82,9 @@ struct RealtimeTopListCard: View { } else if let error = viewModel.loadingError { loadingView .redacted(reason: .placeholder) + .opacity(0.1) .overlay { SimpleErrorView(error: error) - .background(Color(.systemBackground)) } } } @@ -102,16 +102,10 @@ struct RealtimeTopListCard: View { return TopListItemsView( data: chartData, itemLimit: 6, - showDetails: false, - showMoreButton: data.items.count > 6 || viewModel.isFirstLoad, - onShowMore: { - // Show more action - } + showDetails: false ) } - - - + private var loadingView: some View { topListItemsView(data: mockData) } diff --git a/Modules/Sources/JetpackStats/Cards/TopListCard.swift b/Modules/Sources/JetpackStats/Cards/TopListCard.swift index 929270c8dad5..cfb1c7347370 100644 --- a/Modules/Sources/JetpackStats/Cards/TopListCard.swift +++ b/Modules/Sources/JetpackStats/Cards/TopListCard.swift @@ -1,40 +1,20 @@ import SwiftUI struct TopListCard: View { - let dateRange: StatsDateRange - let availableItems: [TopListItemType] - - @StateObject private var viewModel: TopListCardViewModel - @State private var selectedItem: TopListItemType - @State private var selectedMetric: SiteMetric = .views - - private let itemLimit = 6 + @ObservedObject private var viewModel: TopListCardViewModel @Environment(\.context) var context - init( - dateRange: StatsDateRange, - availableDataTypes: [TopListItemType] = TopListItemType.allCases, - initialDataType: TopListItemType = .postsAndPages, - service: any StatsServiceProtocol - ) { - self.dateRange = dateRange - self.availableItems = availableDataTypes - - let selectedItem = availableDataTypes.contains(initialDataType) ? initialDataType : availableDataTypes.first ?? .postsAndPages - self._selectedItem = State(initialValue: selectedItem) - - let viewModel = TopListCardViewModel(service: service) - self._viewModel = StateObject(wrappedValue: viewModel) + private let itemLimit = 6 - viewModel.setSelectedMetric(.views) - viewModel.loadData(for: selectedItem, dateRange: self.dateRange, metric: .views) + init(viewModel: TopListCardViewModel) { + self.viewModel = viewModel } var body: some View { VStack(alignment: .leading, spacing: 16) { HStack { - StatsCardTitleView(title: selectedItem.getTitle(for: selectedMetric)) + StatsCardTitleView(title: viewModel.title) Spacer(minLength: 44) } VStack(spacing: 12) { @@ -42,55 +22,52 @@ struct TopListCard: View { contentView } } + .onAppear { + viewModel.onAppear() + } .padding(Constants.step2) .overlay(alignment: .topTrailing) { moreMenu } .grayscale(viewModel.isStale ? 1 : 0) .animation(.smooth, value: viewModel.isStale) - .onChange(of: selectedItem) { newValue in - // Reset to views when data type changes, as not all metrics are available for all types - selectedMetric = .views - viewModel.loadData(for: newValue, dateRange: dateRange, metric: selectedMetric) - } - .onChange(of: selectedMetric) { _ in - viewModel.setSelectedMetric(selectedMetric) - viewModel.loadData(for: selectedItem, dateRange: dateRange, metric: selectedMetric) - } - .onChange(of: dateRange) { _ in - viewModel.loadData(for: selectedItem, dateRange: dateRange, metric: selectedMetric) - } } private var headerView: some View { HStack { Menu { - ForEach(availableItems) { dataType in + ForEach(viewModel.items) { item in Button { - selectedItem = dataType + var selection = viewModel.selection + selection.item = item + if !item.availableMetrics.contains(selection.metric), + let metric = item.availableMetrics.first { + selection.metric = metric + } + viewModel.selection = selection } label: { - Label(dataType.localizedTitle, systemImage: dataType.systemImage) + Label(item.localizedTitle, systemImage: item.systemImage) } } .tint(Color.primary) } label: { - InlineValuePickerTitle(title: selectedItem.localizedTitle) + InlineValuePickerTitle(title: viewModel.selection.item.localizedTitle) } .fixedSize() Spacer() Menu { - ForEach(selectedItem.availableMetrics) { metric in + ForEach(viewModel.selection.item.availableMetrics) { metric in Button { - selectedMetric = metric + viewModel.selection.metric = metric } label: { Label(metric.localizedTitle, systemImage: metric.systemImage) } } .tint(Color.primary) } label: { - InlineValuePickerTitle(title: selectedMetric.localizedTitle) + InlineValuePickerTitle(title: viewModel.selection.metric.localizedTitle) } .fixedSize() } @@ -127,82 +104,64 @@ struct TopListCard: View { .redacted(reason: .placeholder) } else if let data = viewModel.matchedData { topListItemsView(data: data) - } else if let error = viewModel.loadingError { + } else { topListItemsView(data: mockData) .redacted(reason: .placeholder) .grayscale(1) - .opacity(0.8) + .opacity(0.33) .overlay { - SimpleErrorView(error: error) - .background(Color(.systemBackground).opacity(0.66)) + SimpleErrorView(message: viewModel.loadingError?.localizedDescription ?? Strings.Errors.generic) } } } } private func topListItemsView(data: TopListChartData) -> some View { - TopListItemsView( - data: data, - itemLimit: itemLimit, - showDetails: true, - showMoreButton: true, - onShowMore: { - // Not implemented + VStack(spacing: 0) { + ZStack(alignment: .top) { + // Ensure consistent sizing + TopListItemsView(data: mockData, itemLimit: itemLimit) + .opacity(0) + TopListItemsView(data: data, itemLimit: itemLimit) } - ) + showMoreButton + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private var showMoreButton: some View { + Button { + // Not implementd + } label: { + HStack(spacing: 4) { + Text(Strings.Buttons.showAll) + .padding(.trailing, 4) + .font(.callout) + .foregroundColor(.primary) + Image(systemName: "chevron.right") + .font(.caption) + .foregroundColor(.secondary) + } + .font(.body) + } + .padding(.top, 16) + .tint(Color.secondary.opacity(0.8)) } private var mockData: TopListChartData { - TopListChartData.mock(for: selectedItem, metric: selectedMetric, itemCount: itemLimit) + TopListChartData.mock(for: viewModel.selection.item, metric: viewModel.selection.metric, itemCount: itemLimit) } } -// MARK: - Preview - #Preview { - ScrollView { - VStack(spacing: 20) { - // Posts & Pages - TopListCard( - dateRange: Calendar.demo.makeDateRange(for: .last7Days), - availableDataTypes: [.postsAndPages, .posts, .pages], - initialDataType: .postsAndPages, - service: MockStatsService() - ) - .background(Color(.systemBackground)) - .cornerRadius(12) - - // Referrers - TopListCard( - dateRange: Calendar.demo.makeDateRange(for: .last30Days), - availableDataTypes: [.referrers], - initialDataType: .referrers, - service: MockStatsService() - ) - .background(Color(.systemBackground)) - .cornerRadius(12) - - // Locations - TopListCard( - dateRange: Calendar.demo.makeDateRange(for: .last30Days), - availableDataTypes: [.locations], - initialDataType: .locations, - service: MockStatsService() - ) - .background(Color(.systemBackground)) - .cornerRadius(12) - - // Authors - TopListCard( - dateRange: Calendar.demo.makeDateRange(for: .last30Days), - availableDataTypes: [.authors], - initialDataType: .authors, - service: MockStatsService() - ) - .background(Color(.systemBackground)) - .cornerRadius(12) - } - .padding() - } - .background(Color(.systemGroupedBackground)) + TopListCard(viewModel: TopListCardViewModel( + selection: .init( + item: .postsAndPages, + metric: .views + ), + dateRange: Calendar.demo.makeDateRange(for: .last28Days), + service: MockStatsService() + )) + .cardStyle() + .padding() } diff --git a/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift b/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift index 2cf8c194eaa1..e6c9c1291afc 100644 --- a/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift +++ b/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift @@ -1,11 +1,22 @@ import SwiftUI @MainActor -final class TopListCardViewModel: ObservableObject { - @Published var matchedData: TopListChartData? - @Published var isLoading = true - @Published var loadingError: Error? - @Published var isStale = false +final class TopListCardViewModel: ObservableObject, TrafficCardViewModel { + let items: [TopListItemType] + + var title: String { + selection.item.getTitle(for: selection.metric) + } + + @Published var selection: Selection { + didSet { + loadData() + } + } + @Published private(set) var matchedData: TopListChartData? + @Published private(set) var isLoading = true + @Published private(set) var loadingError: Error? + @Published private(set) var isStale = false private let service: any StatsServiceProtocol @@ -13,13 +24,33 @@ final class TopListCardViewModel: ObservableObject { private var loadRequestCount = 0 private var staleTimer: Task? + var dateRange: StatsDateRange { + didSet { loadData() } + } + + struct Selection: Equatable { + var item: TopListItemType + var metric: SiteMetric + } + var isFirstLoad: Bool { isLoading && matchedData == nil } - init(service: any StatsServiceProtocol) { + private var isFirstAppear = true + + init(selection: Selection, dateRange: StatsDateRange, service: any StatsServiceProtocol) { + self.items = service.supportedItems + self.selection = selection + self.dateRange = dateRange self.service = service } - func loadData(for item: TopListItemType, dateRange: StatsDateRange, metric: SiteMetric) { + func onAppear() { + guard isFirstAppear else { return } + isFirstAppear = false + loadData() + } + + private func loadData() { loadingTask?.cancel() staleTimer?.cancel() @@ -31,14 +62,14 @@ final class TopListCardViewModel: ObservableObject { // no response in more than T seconds. if matchedData != nil { staleTimer = Task { [weak self] in - try? await Task.sleep(for: .seconds(1)) + try? await Task.sleep(for: .seconds(2)) guard !Task.isCancelled else { return } self?.isStale = true } } // Create a new loading task - loadingTask = Task { [weak self] in + loadingTask = Task { [selection, dateRange, weak self] in guard let self else { return } // Add delay for subsequent requests to avoid rapid API calls when @@ -48,19 +79,18 @@ final class TopListCardViewModel: ObservableObject { } guard !Task.isCancelled else { return } - self.selectedMetric = metric - await self.actuallyLoadData(for: item, dateRange: dateRange, metric: metric) + await self.actuallyLoadData(for: selection, dateRange: dateRange) } } - private func actuallyLoadData(for item: TopListItemType, dateRange: StatsDateRange, metric: SiteMetric) async { + private func actuallyLoadData(for selection: Selection, dateRange: StatsDateRange) async { isLoading = true loadingError = nil do { try Task.checkCancellation() - let data = try await getTopListData(for: item, dateRange: dateRange) + let data = try await getTopListData(for: selection, dateRange: dateRange) // Check for cancellation before updating the state try Task.checkCancellation() @@ -81,18 +111,18 @@ final class TopListCardViewModel: ObservableObject { isLoading = false } - private func getTopListData(for item: TopListItemType, dateRange: StatsDateRange) async throws -> TopListChartData { + private func getTopListData(for selection: Selection, dateRange: StatsDateRange) async throws -> TopListChartData { let granularity = dateRange.dateInterval.preferredGranularity // Fetch both current and previous period data concurrently async let currentTask = service.getTopListData( - item, - range: dateRange.dateInterval, + selection.item, + interval: dateRange.dateInterval, granularity: granularity ) async let previousTask = service.getTopListData( - item, - range: dateRange.effectiveComparisonInterval, + selection.item, + interval: dateRange.effectiveComparisonInterval, granularity: granularity ) @@ -105,21 +135,11 @@ final class TopListCardViewModel: ObservableObject { } // Calculate max value from current items based on selected metric - let metric = selectedMetric ?? .views + let metric = selection.metric let maxValue = current.items .compactMap { $0.metrics[metric] } .max() ?? 1 - return TopListChartData(item: item, metric: metric, items: matchedItems, maxValue: maxValue) - } - - var maxValue: Int { - matchedData?.maxValue ?? 1 - } - - private var selectedMetric: SiteMetric? - - func setSelectedMetric(_ metric: SiteMetric) { - selectedMetric = metric + return TopListChartData(item: selection.item, metric: metric, items: matchedItems, maxValue: maxValue) } } diff --git a/Modules/Sources/JetpackStats/Charts/BarChartView.swift b/Modules/Sources/JetpackStats/Charts/BarChartView.swift index f07ef8aa8750..ed2c0e851220 100644 --- a/Modules/Sources/JetpackStats/Charts/BarChartView.swift +++ b/Modules/Sources/JetpackStats/Charts/BarChartView.swift @@ -22,6 +22,7 @@ struct BarChartView: View { .chartXAxis { xAxis } .chartYAxis { yAxis } .chartLegend(.hidden) + .environment(\.timeZone, context.timeZone) .modifier(ChartSelectionModifier(selection: $selectedDate)) .animation(.spring, value: ObjectIdentifier(data)) .onChange(of: selectedDate) { diff --git a/Modules/Sources/JetpackStats/Charts/ChartData.swift b/Modules/Sources/JetpackStats/Charts/ChartData.swift index de84b7a728b0..30159893bcd0 100644 --- a/Modules/Sources/JetpackStats/Charts/ChartData.swift +++ b/Modules/Sources/JetpackStats/Charts/ChartData.swift @@ -3,17 +3,22 @@ import SwiftUI final class ChartData { let metric: SiteMetric let granularity: DateRangeGranularity + let currentTotal: Int let currentData: [DataPoint] + let previousTotal: Int let previousData: [DataPoint] let mappedPreviousData: [DataPoint] - lazy private(set) var currentTotal = DataPoint.getTotalValue(for: currentData, metric: metric) - lazy private(set) var previousTotal = DataPoint.getTotalValue(for: previousData, metric: metric) + var isEmpty: Bool { + currentData.isEmpty && previousData.isEmpty + } - init(metric: SiteMetric, granularity: DateRangeGranularity, currentData: [DataPoint], previousData: [DataPoint], mappedPreviousData: [DataPoint]) { + init(metric: SiteMetric, granularity: DateRangeGranularity, currentTotal: Int, currentData: [DataPoint], previousTotal: Int, previousData: [DataPoint], mappedPreviousData: [DataPoint]) { self.metric = metric self.granularity = granularity + self.currentTotal = currentTotal self.currentData = currentData + self.previousTotal = previousTotal self.previousData = previousData self.mappedPreviousData = mappedPreviousData } @@ -38,7 +43,9 @@ extension ChartData { return ChartData( metric: metric, granularity: granularity, + currentTotal: DataPoint.getTotalValue(for: dataPoints, metric: metric) ?? 0, currentData: dataPoints, + previousTotal: DataPoint.getTotalValue(for: previousData, metric: metric) ?? 0, previousData: previousData, mappedPreviousData: previousData ) @@ -73,6 +80,7 @@ extension ChartData { case .visitors: 500...2500 case .likes: 50...300 case .comments: 10...100 + case .posts: 10...50 case .timeOnSite: 120...300 case .bounceRate: 40...80 } diff --git a/Modules/Sources/JetpackStats/Charts/LineChartView.swift b/Modules/Sources/JetpackStats/Charts/LineChartView.swift index 5333a37928d5..22e4f0096068 100644 --- a/Modules/Sources/JetpackStats/Charts/LineChartView.swift +++ b/Modules/Sources/JetpackStats/Charts/LineChartView.swift @@ -33,6 +33,7 @@ struct LineChartView: View { .chartXAxis { xAxis } .chartYAxis { yAxis } .chartLegend(.hidden) + .environment(\.timeZone, context.timeZone) .modifier(ChartSelectionModifier(selection: $selectedDate)) .animation(.spring, value: ObjectIdentifier(data)) .onChange(of: selectedDate) { diff --git a/Modules/Sources/JetpackStats/Constants.swift b/Modules/Sources/JetpackStats/Constants.swift index 533e4d817fb2..5445b5602ebd 100644 --- a/Modules/Sources/JetpackStats/Constants.swift +++ b/Modules/Sources/JetpackStats/Constants.swift @@ -34,6 +34,7 @@ enum Constants { static let green = Color(palette: CSColor.Green.self) static let orange = Color(palette: CSColor.Orange.self) static let pink = Color(palette: CSColor.Pink.self) + static let celadon = Color(palette: CSColor.Celadon.self) } static let step1: CGFloat = 12 diff --git a/Modules/Sources/JetpackStats/Screens/ChartDataListView.swift b/Modules/Sources/JetpackStats/Screens/ChartDataListView.swift index c24780df8903..3dd1e15debd2 100644 --- a/Modules/Sources/JetpackStats/Screens/ChartDataListView.swift +++ b/Modules/Sources/JetpackStats/Screens/ChartDataListView.swift @@ -281,11 +281,13 @@ struct RoundedCorner: Shape { .views: ChartData( metric: .views, granularity: .day, + currentTotal: 3000, currentData: [ DataPoint(date: Date(), value: 1000), DataPoint(date: Date().addingTimeInterval(-86400), value: 1200), DataPoint(date: Date().addingTimeInterval(-172800), value: 800) ], + previousTotal: 2750, previousData: [ DataPoint(date: Date().addingTimeInterval(-604800), value: 900), DataPoint(date: Date().addingTimeInterval(-691200), value: 1100), diff --git a/Modules/Sources/JetpackStats/Screens/TrafficTabView.swift b/Modules/Sources/JetpackStats/Screens/TrafficTabView.swift index dc3af9d43b90..ab98b2841f18 100644 --- a/Modules/Sources/JetpackStats/Screens/TrafficTabView.swift +++ b/Modules/Sources/JetpackStats/Screens/TrafficTabView.swift @@ -3,6 +3,7 @@ import SwiftUI struct TrafficTabView: View { @State private var dateRange: StatsDateRange @State private var isShowingCustomRangePicker = false + @State private var viewModels: [any TrafficCardViewModel] = [] @Environment(\.context) var context @@ -13,12 +14,20 @@ struct TrafficTabView: View { var body: some View { ScrollView(showsIndicators: false) { VStack(spacing: Constants.step3) { - overviewChart - postAndPagesList - authorsList + ForEach(viewModels, id: \.id) { viewModel in + makeItem(for: viewModel) + } } .padding(.vertical, Constants.step2) } + .onAppear { + configureViewModels() + } + .onChange(of: dateRange) { + for viewModel in viewModels { + viewModel.dateRange = $0 + } + } .background(Constants.Colors.statsBackground) .toolbar { if #available(iOS 26, *) { @@ -35,6 +44,39 @@ struct TrafficTabView: View { } } + @ViewBuilder + private func makeItem(for viewModel: TrafficCardViewModel) -> some View { + switch viewModel { + case let viewModel as ChartCardViewModel: + ChartCard(viewModel: viewModel) + .cardStyle() + case let viewModel as TopListCardViewModel: + TopListCard(viewModel: viewModel) + .cardStyle() + default: + let _ = assertionFailure("Unsupported type: \(viewModel)") + EmptyView() + } + } + + private func configureViewModels() { + guard viewModels.isEmpty else { + return + } + viewModels = [ + ChartCardViewModel( + metrics: context.service.supportedMetrics, + dateRange: dateRange, + service: context.service + ), + TopListCardViewModel( + selection: .init(item: .postsAndPages, metric: .views), + dateRange: dateRange, + service: context.service + ) + ] + } + // MARK: - Toolbar @ToolbarContentBuilder @@ -90,35 +132,4 @@ struct TrafficTabView: View { let localizedText = context.formatters.dateRange.string(from: range) return localizedText } - - // MARK: - Cards - - private var overviewChart: some View { - ChartCard( - metrics: SiteMetric.allCases, - dateRange: dateRange, - service: context.service - ) - .cardStyle() - } - - private var postAndPagesList: some View { - TopListCard( - dateRange: dateRange, - availableDataTypes: TopListItemType.allCases, - initialDataType: .postsAndPages, - service: context.service - ) - .cardStyle() - } - - private var authorsList: some View { - TopListCard( - dateRange: dateRange, - availableDataTypes: TopListItemType.allCases, - initialDataType: .authors, - service: context.service - ) - .cardStyle() - } } diff --git a/Modules/Sources/JetpackStats/Services/Data/DataPoint.swift b/Modules/Sources/JetpackStats/Services/Data/DataPoint.swift index 5801de292135..18be43ab497f 100644 --- a/Modules/Sources/JetpackStats/Services/Data/DataPoint.swift +++ b/Modules/Sources/JetpackStats/Services/Data/DataPoint.swift @@ -35,9 +35,9 @@ extension DataPoint { } } - static func getTotalValue(for dataPoints: [DataPoint], metric: SiteMetric) -> Int { + static func getTotalValue(for dataPoints: [DataPoint], metric: SiteMetric) -> Int? { guard !dataPoints.isEmpty else { - return 0 + return nil } let total = dataPoints.reduce(0) { $0 + $1.value } switch metric.aggregarionStrategy { diff --git a/Modules/Sources/JetpackStats/Services/Data/SiteMetric.swift b/Modules/Sources/JetpackStats/Services/Data/SiteMetric.swift index 33420740212c..a20d02f8be32 100644 --- a/Modules/Sources/JetpackStats/Services/Data/SiteMetric.swift +++ b/Modules/Sources/JetpackStats/Services/Data/SiteMetric.swift @@ -5,6 +5,7 @@ enum SiteMetric: CaseIterable, Identifiable { case visitors case likes case comments + case posts case timeOnSite case bounceRate @@ -16,6 +17,7 @@ enum SiteMetric: CaseIterable, Identifiable { case .visitors: Strings.SiteMetrics.visitors case .likes: Strings.SiteMetrics.likes case .comments: Strings.SiteMetrics.comments + case .posts: Strings.SiteMetrics.posts case .timeOnSite: Strings.SiteMetrics.timeOnSite case .bounceRate: Strings.SiteMetrics.bounceRate } @@ -27,6 +29,7 @@ enum SiteMetric: CaseIterable, Identifiable { case .visitors: "person.2" case .likes: "star" case .comments: "bubble.left" + case .posts: "paragraphsign" case .timeOnSite: "clock" case .bounceRate: "rectangle.portrait.and.arrow.right" } @@ -38,6 +41,7 @@ enum SiteMetric: CaseIterable, Identifiable { case .visitors: Constants.Colors.purple case .likes: Constants.Colors.red case .comments: Constants.Colors.green + case .posts: Constants.Colors.celadon case .timeOnSite: Constants.Colors.orange case .bounceRate: Constants.Colors.pink } @@ -51,7 +55,7 @@ enum SiteMetric: CaseIterable, Identifiable { extension SiteMetric { var isHigherValueBetter: Bool { switch self { - case .views, .visitors, .likes, .comments, .timeOnSite: + case .views, .visitors, .likes, .comments, .timeOnSite, .posts: return true case .bounceRate: return false @@ -60,7 +64,7 @@ extension SiteMetric { var aggregarionStrategy: AggregationStrategy { switch self { - case .views, .visitors, .likes, .comments: + case .views, .visitors, .likes, .comments, .posts: return .sum case .timeOnSite, .bounceRate: return .average diff --git a/Modules/Sources/JetpackStats/Services/Data/SiteMetricsData.swift b/Modules/Sources/JetpackStats/Services/Data/SiteMetricsData.swift new file mode 100644 index 000000000000..fa0811f39efa --- /dev/null +++ b/Modules/Sources/JetpackStats/Services/Data/SiteMetricsData.swift @@ -0,0 +1,13 @@ +import Foundation + +struct SiteMetricsData: Sendable { + var total: SiteMetricsSet + + /// Data points with the requested granularity. + /// + /// - note: The dates are in the site reporting time zone. + /// + /// - warning: Hourly data is not available for some metrics, but total + /// metrics still are. + var metrics: [SiteMetric: [DataPoint]] +} diff --git a/Modules/Sources/JetpackStats/Services/Data/SiteMetricsSet.swift b/Modules/Sources/JetpackStats/Services/Data/SiteMetricsSet.swift new file mode 100644 index 000000000000..6ddc56fb7607 --- /dev/null +++ b/Modules/Sources/JetpackStats/Services/Data/SiteMetricsSet.swift @@ -0,0 +1,38 @@ +import Foundation + +/// A memory-efficient collection of metrics with direct memory layout and no +/// heap allocations. +struct SiteMetricsSet: Codable { + var views: Int? + var visitors: Int? + var likes: Int? + var comments: Int? + var posts: Int? + var bounceRate: Int? + var timeOnSite: Int? + + subscript(metric: SiteMetric) -> Int? { + get { + switch metric { + case .views: views + case .visitors: visitors + case .likes: likes + case .comments: comments + case .posts: posts + case .bounceRate: bounceRate + case .timeOnSite: timeOnSite + } + } + set { + switch metric { + case .views: views = newValue + case .visitors: visitors = newValue + case .likes: likes = newValue + case .comments: comments = newValue + case .posts: posts = newValue + case .bounceRate: bounceRate = newValue + case .timeOnSite: timeOnSite = newValue + } + } + } +} diff --git a/Modules/Sources/JetpackStats/Services/Data/SiteStatsData.swift b/Modules/Sources/JetpackStats/Services/Data/SiteStatsData.swift deleted file mode 100644 index a9727e31707a..000000000000 --- a/Modules/Sources/JetpackStats/Services/Data/SiteStatsData.swift +++ /dev/null @@ -1,5 +0,0 @@ -import Foundation - -struct SiteStatsData: Sendable { - var metrics: [SiteMetric: [DataPoint]] -} diff --git a/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift b/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift index 67213e64e0f1..008ad8aaccde 100644 --- a/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift +++ b/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift @@ -85,6 +85,7 @@ extension TopListChartData { return TopListData.Post( title: data.0, postId: "\(index + 1)", + date: nil, pageId: nil, type: nil, author: data.1, @@ -188,94 +189,45 @@ extension TopListChartData { } } - private static func createMetrics(baseValue: Int, metric: SiteMetric) -> TopListData.Metrics { + 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) let value = Int(Double(baseValue) * variation) switch metric { case .views: - return TopListData.Metrics(views: value) + return SiteMetricsSet(views: value) case .visitors: // Visitors are typically 60-80% of views let visitorRatio = Double.random(in: 0.6...0.8) - return TopListData.Metrics(visitors: Int(Double(value) * visitorRatio)) + return SiteMetricsSet(visitors: Int(Double(value) * visitorRatio)) case .likes: // Likes are typically 2-5% of views let likeRatio = Double.random(in: 0.02...0.05) - return TopListData.Metrics(likes: Int(Double(value) * likeRatio)) + return SiteMetricsSet(likes: Int(Double(value) * likeRatio)) case .comments: // Comments are typically 0.5-2% of views let commentRatio = Double.random(in: 0.005...0.02) - return TopListData.Metrics(comments: Int(Double(value) * commentRatio)) + return SiteMetricsSet(comments: Int(Double(value) * commentRatio)) + case .posts: + let postsRatio = Double.random(in: 0.002...0.005) + return SiteMetricsSet(comments: Int(Double(value) * postsRatio)) case .timeOnSite: // Time on site not applicable for top list items - return TopListData.Metrics(views: value) + return SiteMetricsSet(views: value) case .bounceRate: // Bounce rate not applicable for top list items - return TopListData.Metrics(views: value) + return SiteMetricsSet(views: value) } } private static func mockPreviousItem(from item: any TopListItem, metric: SiteMetric) -> any TopListItem { + var item = item + // Create previous value that's 70-130% of current value for realistic trends let trendFactor = Double.random(in: 0.7...1.3) let currentValue = item.metrics[metric] ?? 0 - let previousValue = Int(Double(currentValue) * trendFactor) - - var previousMetrics = item.metrics - switch metric { - case .views: - previousMetrics.views = previousValue - case .visitors: - previousMetrics.visitors = previousValue - case .likes: - previousMetrics.likes = previousValue - case .comments: - previousMetrics.comments = previousValue - case .timeOnSite, .bounceRate: - // These metrics are not applicable for top list items - previousMetrics.views = previousValue - } - - // Return the same item type with updated metrics - if let post = item as? TopListData.Post { - return TopListData.Post( - title: post.title, - postId: post.postId, - pageId: post.pageId, - type: post.type, - author: post.author, - metrics: previousMetrics - ) - } else if let referrer = item as? TopListData.Referrer { - return TopListData.Referrer( - name: referrer.name, - domain: referrer.domain, - metrics: previousMetrics - ) - } else if let location = item as? TopListData.Location { - return TopListData.Location( - country: location.country, - flag: location.flag, - countryCode: location.countryCode, - metrics: previousMetrics - ) - } else if let author = item as? TopListData.Author { - return TopListData.Author( - name: author.name, - userId: author.userId, - role: author.role, - metrics: previousMetrics, - avatarURL: author.avatarURL - ) - } else if let link = item as? TopListData.ExternalLink { - return TopListData.ExternalLink( - url: link.url, - title: link.title, - metrics: previousMetrics - ) - } + item.metrics[metric] = Int(Double(currentValue) * trendFactor) return item } diff --git a/Modules/Sources/JetpackStats/Services/Data/TopListData.swift b/Modules/Sources/JetpackStats/Services/Data/TopListData.swift index ff7253e3ed16..8974e595e11f 100644 --- a/Modules/Sources/JetpackStats/Services/Data/TopListData.swift +++ b/Modules/Sources/JetpackStats/Services/Data/TopListData.swift @@ -5,38 +5,19 @@ struct TopListData: Sendable { } protocol TopListItem: Codable, Sendable, Identifiable { - var metrics: TopListData.Metrics { get set } + var metrics: SiteMetricsSet { get set } var id: String { get } } extension TopListData { - struct Metrics: Codable { - var views: Int? - var visitors: Int? - var likes: Int? - var comments: Int? - var bounceRate: Int? - var timeOnSite: Int? - - subscript(metric: SiteMetric) -> Int? { - switch metric { - case .views: views - case .visitors: visitors - case .likes: likes - case .comments: comments - case .bounceRate: bounceRate - case .timeOnSite: timeOnSite - } - } - } - struct Post: Codable, TopListItem { let title: String let postId: String? + let date: Date? let pageId: String? let type: String? let author: String? - var metrics: Metrics + var metrics: SiteMetricsSet var id: String { postId ?? pageId ?? title } } @@ -44,7 +25,7 @@ extension TopListData { struct Referrer: Codable, TopListItem { let name: String let domain: String? - var metrics: Metrics + var metrics: SiteMetricsSet var id: String { domain ?? name } } @@ -53,7 +34,7 @@ extension TopListData { let country: String let flag: String? let countryCode: String? - var metrics: Metrics + var metrics: SiteMetricsSet var id: String { countryCode ?? country } } @@ -62,7 +43,7 @@ extension TopListData { let name: String let userId: String let role: String? - var metrics: Metrics + var metrics: SiteMetricsSet var avatarURL: URL? var id: String { userId } @@ -71,7 +52,7 @@ extension TopListData { struct ExternalLink: Codable, TopListItem { let url: String let title: String? - var metrics: Metrics + var metrics: SiteMetricsSet var id: String { url } } diff --git a/Modules/Sources/JetpackStats/Services/Data/TopListItemType.swift b/Modules/Sources/JetpackStats/Services/Data/TopListItemType.swift index 62813d94478e..3a053a277676 100644 --- a/Modules/Sources/JetpackStats/Services/Data/TopListItemType.swift +++ b/Modules/Sources/JetpackStats/Services/Data/TopListItemType.swift @@ -38,8 +38,8 @@ enum TopListItemType: Identifiable, CaseIterable { var availableMetrics: [SiteMetric] { switch self { case .postsAndPages, .posts, .pages: [.views, .visitors, .comments, .likes] - case .referrers: SiteMetric.allCases - case .locations: SiteMetric.allCases + case .referrers: [.views, .views] + case .locations: [.views, .views] case .authors: [.views, .comments, .likes] case .externalLinks: [.views, .visitors] } @@ -51,6 +51,7 @@ enum TopListItemType: Identifiable, CaseIterable { case .visitors: Strings.TopListTitles.mostVisitors case .comments: Strings.TopListTitles.mostCommented case .likes: Strings.TopListTitles.mostLiked + case .posts: Strings.TopListTitles.mostPosts case .bounceRate: Strings.TopListTitles.highestBounceRate case .timeOnSite: Strings.TopListTitles.longestTimeOnSite } diff --git a/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift b/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift index a57c74889fc9..a89968a1e1e0 100644 --- a/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift +++ b/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift @@ -6,6 +6,9 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { private var dailyTopListData: [TopListItemType: [Date: [any TopListItem]]] = [:] private let calendar: Calendar + let supportedMetrics = SiteMetric.allCases + let supportedItems = TopListItemType.allCases + /// - parameter timeZone: The reporting time zone of a site. init(timeZone: TimeZone = .current) { var calendar = Calendar.current @@ -21,23 +24,28 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { await generateTopListMockData() } - func getSiteStats(interval: DateInterval, granularity: DateRangeGranularity) async throws -> SiteStatsData { + func getSiteStats(interval: DateInterval, granularity: DateRangeGranularity) async throws -> SiteMetricsData { await generateDataIfNeeded() + + var total = SiteMetricsSet() var output: [SiteMetric: [DataPoint]] = [:] + for (metric, dataPoints) in hourlyData { // This isn't efficient by any means but it will do for the mocking purposes let filteredDataPoints = dataPoints.filter { interval.start <= $0.date && $0.date < interval.end } - output[metric] = aggregateData(filteredDataPoints, granularity: granularity, range: interval, metric: metric) + let dataPoints = aggregateData(filteredDataPoints, granularity: granularity, range: interval, metric: metric) + output[metric] = dataPoints + total[metric] = DataPoint.getTotalValue(for: dataPoints, metric: metric) } - try? await Task.sleep(for: .milliseconds(Int.random(in: 200...600))) + try? await Task.sleep(for: .milliseconds(Int.random(in: 200...500))) - return SiteStatsData(metrics: output) + return SiteMetricsData(total: total, metrics: output) } - func getTopListData(_ dataType: TopListItemType, range: DateInterval, granularity: DateRangeGranularity) async throws -> TopListData { + func getTopListData(_ dataType: TopListItemType, interval: DateInterval, granularity: DateRangeGranularity) async throws -> TopListData { await generateDataIfNeeded() guard let typeData = dailyTopListData[dataType] else { @@ -46,7 +54,7 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { // Filter data within the date range let filteredData = typeData.filter { date, _ in - range.start <= date && date < range.end + interval.start <= date && date < interval.end } // Aggregate all items across the date range @@ -74,7 +82,7 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { } .sorted { ($0.metrics.views ?? 0) > ($1.metrics.views ?? 0) } - try? await Task.sleep(for: .milliseconds(Int.random(in: 200...600))) + try? await Task.sleep(for: .milliseconds(Int.random(in: 200...500))) return TopListData(items: Array(sortedItems.prefix(20))) } @@ -387,6 +395,10 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { let baseValue = 3.0 return Int(baseValue * growthFactor * seasonalFactor * weekendFactor * randomFactor) + case .posts: + let baseValue = 1.0 + return Int(baseValue * growthFactor * seasonalFactor * weekendFactor * randomFactor) + case .timeOnSite: // Time in seconds - doesn't follow same patterns return Int(170 + Double.random(in: -40...40)) diff --git a/Modules/Sources/JetpackStats/Services/StatsService.swift b/Modules/Sources/JetpackStats/Services/StatsService.swift index 677b99dbfc61..602cda68bf01 100644 --- a/Modules/Sources/JetpackStats/Services/StatsService.swift +++ b/Modules/Sources/JetpackStats/Services/StatsService.swift @@ -1,128 +1,203 @@ import Foundation +import WordPressShared @preconcurrency import WordPressKit -final class StatsService: StatsServiceProtocol { +/// - warning: The dates in StatsServiceRemoteV2 are represented in TimeZone.local +/// despite it accepting `siteTimezone` as a parameter. The parameter was +/// added later and is only used in a small subset of methods, which means +/// thay we have to convert the dates from the local time zone to the +/// site reporting time zone (as expected by the app). +actor StatsService: StatsServiceProtocol { private let siteID: Int private let api: WordPressComRestApi - private let remoteService: StatsServiceRemoteV2 + private let service: StatsServiceRemoteV2 + private let siteTimeZone: TimeZone + // Temporary + private var mocks: MockStatsService - init(siteID: Int, api: WordPressComRestApi, siteTimezone: TimeZone) { + let supportedMetrics: [SiteMetric] = [ + .views, .visitors, .likes, .comments, .posts + ] + + let supportedItems: [TopListItemType] = [ + .postsAndPages + ] + + init(siteID: Int, api: WordPressComRestApi, timeZone: TimeZone) { self.siteID = siteID self.api = api - self.remoteService = StatsServiceRemoteV2( + self.service = StatsServiceRemoteV2( wordPressComRestApi: api, siteID: siteID, - siteTimezone: siteTimezone + siteTimezone: timeZone ) + self.siteTimeZone = timeZone + self.mocks = MockStatsService(timeZone: timeZone) } - /// - warning: The dates in StatsServiceRemoteV2 are represented in TimeZone.local - /// despite it accepting `siteTimezone` as a parameter. The parameter was - /// added later and is only used in a small subset of methods, which means - /// thay we have to convert the dates from the local time zone to the - /// site reporting time zone (as expected by the app). - func getSiteStats(interval: DateInterval, granularity: DateRangeGranularity) async throws -> SiteStatsData { - let period = mapGranularityToPeriod(granularity) - let endDate = interval.end - let summaryData: StatsSummaryTimeIntervalData = try await remoteService.getData(for: period, endingOn: endDate) - return mapToSiteStatsData(summaryData, interval: interval, granularity: granularity) + // MARK: - StatsServiceProtocol + + func getSiteStats(interval: DateInterval, granularity: DateRangeGranularity) async throws -> SiteMetricsData { + let interval = convertDateIntervalSiteToLocal(interval) + + if granularity == .hour { + // Hourly data is available only for "Views", so the service has to + // make a separate request to fetch the total metrics. + async let hourlyResponseTask: WordPressKit.StatsSiteMetricsResponse = service.getData(interval: interval, unit: .init(granularity)) + async let dailyResponseTask: WordPressKit.StatsSiteMetricsResponse = service.getData(interval: interval, unit: .init(.day)) + + let (hourlyResponse, dailyResponse) = try await (hourlyResponseTask, dailyResponseTask) + + var data = mapSiteMetricsResponse(hourlyResponse) + data.total = mapSiteMetricsResponse(dailyResponse).total + return data + } else { + let response: WordPressKit.StatsSiteMetricsResponse = try await service.getData(interval: interval, unit: .init(granularity)) + return mapSiteMetricsResponse(response) + } + } + + func getTopListData(_ item: TopListItemType, interval: DateInterval, granularity: DateRangeGranularity) async throws -> TopListData { + do { + return try await _getTopListData(item, interval: interval, granularity: granularity) + } catch { + // A workaround for an issue where `/stats` return `"summary": null` + // when there are no recoreded periods (happens when the entire requested + // period is _before_ the site creation). + if let error = error as? StatsServiceRemoteV2.ResponseError, + error == .emptySummary { + return TopListData(items: []) + } + throw error + } } - func getTopListData(_ dataType: TopListItemType, range: DateInterval, granularity: DateRangeGranularity) async throws -> TopListData { - let period = mapGranularityToPeriod(granularity) - let endDate = range.end + private func _getTopListData(_ item: TopListItemType, interval: DateInterval, granularity: DateRangeGranularity) async throws -> TopListData { - switch dataType { + func getData( + _ type: T.Type, + parameters: [String: String]? = nil + ) async throws -> T where T: Sendable { + /// The `summarize: true` feature works correctly only with the `.day` granularity. + let interval = convertDateIntervalSiteToLocal(interval) + return try await service.getData(interval: interval, unit: .day, summarize: true, parameters: parameters) + } + + switch item { case .postsAndPages: - throw StatsServiceError.notImplemented("Not implemented") + let data = try await getData(StatsTopPostsTimeIntervalData.self) + return mapPostsToTopListData(data) case .pages: - throw StatsServiceError.notImplemented("Not implemented") + throw StatsServiceError.notImplemented case .posts: - let data: StatsTopPostsTimeIntervalData = try await remoteService.getData(for: period, endingOn: endDate) - return mapPostsToTopListData(data) + throw StatsServiceError.notImplemented case .referrers: - let data: StatsTopReferrersTimeIntervalData = try await remoteService.getData(for: period, endingOn: endDate) + let data = try await getData(StatsTopReferrersTimeIntervalData.self) return mapReferrersToTopListData(data) case .locations: - let data: StatsTopCountryTimeIntervalData = try await remoteService.getData(for: period, endingOn: endDate) + let data = try await getData(StatsTopCountryTimeIntervalData.self) return mapCountriesToTopListData(data) case .authors: - let data: StatsTopAuthorsTimeIntervalData = try await remoteService.getData(for: period, endingOn: endDate) + let data = try await getData(StatsTopAuthorsTimeIntervalData.self) return mapAuthorsToTopListData(data) case .externalLinks: - throw StatsServiceError.notImplemented("Not implemented") + throw StatsServiceError.notImplemented } } - func getRealtimeTopListData(_ dataType: TopListItemType) async throws -> TopListData { - // NOTE: Realtime data requires different endpoints that are not yet available in WordPressKit - throw StatsServiceError.notImplemented("Not implemented") + func getRealtimeTopListData(_ item: TopListItemType) async throws -> TopListData { + try await mocks.getRealtimeTopListData(item) } - // MARK: - Private Helpers + // MARK: - Dates + + /// Convert from the site timezone (used in JetpackState) to the local + /// timezone (expected by WordPressKit) while preserving the date components. + /// + /// For .hour unit, WPKit will send "2025-01-01 – 2025-01-07" (inclusive). + /// For other unit, it will send "2025-01-01 00:00:00 – 2025-01-07 23:59:59". + private func convertDateIntervalSiteToLocal(_ dateInterval: DateInterval) -> DateInterval { + let start = convertDateSiteToLocal(dateInterval.start) + let end = convertDateSiteToLocal(dateInterval.end.addingTimeInterval(-1)) + return DateInterval(start: start, end: end) + } - private func mapGranularityToPeriod(_ granularity: DateRangeGranularity) -> StatsPeriodUnit { - switch granularity { - case .hour: -#warning("Not implemented") - return .day - case .day: - return .day - case .month: - return .month - case .year: - return .year + /// Convert from the site timezone (used in JetpackState) to the local + /// timezone (expected by WordPressKit) while preserving the date components. + private func convertDateSiteToLocal(_ date: Date) -> Date { + let calendar = Calendar.current + let components = calendar.dateComponents(in: siteTimeZone, from: date) + guard let output = calendar.date(from: components) else { + wpAssertionFailure("failed to convert date to local time zone", userInfo: ["date": date]) + return date } + return output } - private func mapToSiteStatsData(_ summaryData: StatsSummaryTimeIntervalData, interval: DateInterval, granularity: DateRangeGranularity) -> SiteStatsData { - var metrics: [SiteMetric: [DataPoint]] = [:] + // MARK: - Mapping (WordPressKit -> JetpackStats) - // Map views - metrics[.views] = summaryData.summaryData.map { summary in - DataPoint(date: summary.periodStartDate, value: summary.viewsCount) - } + private func mapSiteMetricsResponse(_ response: WordPressKit.StatsSiteMetricsResponse) -> SiteMetricsData { + var calendar = Calendar.current + calendar.timeZone = siteTimeZone - // Map visitors - metrics[.visitors] = summaryData.summaryData.map { summary in - DataPoint(date: summary.periodStartDate, value: summary.visitorsCount) - } + let now = Date.now - // Map likes - metrics[.likes] = summaryData.summaryData.map { summary in - DataPoint(date: summary.periodStartDate, value: summary.likesCount) + func makeDataPoint(from data: WordPressKit.StatsSiteMetricsResponse.PeriodData, metric: WordPressKit.StatsSiteMetricsResponse.Metric) -> DataPoint? { + guard let value = data[metric] else { + return nil + } + let date: Date = { + let components = calendar.dateComponents(in: TimeZone.current, from: data.date) + guard let output = calendar.date(from: components) else { + wpAssertionFailure("failed to convert date to site time zone", userInfo: ["date": data.date]) + return data.date + } + return output + }() + guard date <= now else { + return nil // Filter out future dates + } + return DataPoint(date: date, value: value) } - // Map comments - metrics[.comments] = summaryData.summaryData.map { summary in - DataPoint(date: summary.periodStartDate, value: summary.commentsCount) + var total = SiteMetricsSet() + var metrics: [SiteMetric: [DataPoint]] = [:] + for metric in supportedMetrics { + let mappedMetric = WordPressKit.StatsSiteMetricsResponse.Metric(metric) + let dataPoints = response.data.compactMap { + makeDataPoint(from: $0, metric: mappedMetric) + } + metrics[metric] = dataPoints + total[metric] = DataPoint.getTotalValue(for: dataPoints, metric: metric) } - - // NOTE: Time on site and bounce rate not available in StatsSummaryData - - return SiteStatsData(metrics: metrics) + 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), + date: post.date.flatMap(dateFormatter.date), pageId: nil, type: post.kind.description, author: nil, - metrics: TopListData.Metrics( + metrics: SiteMetricsSet( views: post.viewsCount ) ) } - return TopListData(items: items) } @@ -131,7 +206,7 @@ final class StatsService: StatsServiceProtocol { TopListData.Referrer( name: referrer.title, domain: referrer.url?.host, - metrics: TopListData.Metrics( + metrics: SiteMetricsSet( views: referrer.viewsCount ) ) @@ -146,7 +221,7 @@ final class StatsService: StatsServiceProtocol { country: country.name, flag: countryCodeToEmoji(country.code), countryCode: country.code, - metrics: TopListData.Metrics( + metrics: SiteMetricsSet( views: country.viewsCount ) ) @@ -161,7 +236,7 @@ final class StatsService: StatsServiceProtocol { name: author.name, userId: author.name, // NOTE: WordPressKit doesn't provide user ID role: nil, - metrics: TopListData.Metrics( + metrics: SiteMetricsSet( views: author.viewsCount ), avatarURL: author.iconURL @@ -186,14 +261,14 @@ final class StatsService: StatsServiceProtocol { enum StatsServiceError: LocalizedError { case noData - case notImplemented(String) - + case notImplemented + var errorDescription: String? { switch self { case .noData: return "No data received from the server" - case .notImplemented(let feature): - return "\(feature)" + case .notImplemented: + return "Not implemented" } } } @@ -222,20 +297,37 @@ private extension StatsTopPost.Kind { } } +// TODO: rework this +private extension WordPressKit.StatsSiteMetricsResponse.Metric { + init(_ metric: SiteMetric) { + switch metric { + case .views: self = .views + case .visitors: self = .visitors + case .likes: self = .likes + case .comments: self = .comments + case .posts: self = .posts + case .timeOnSite, .bounceRate: + wpAssertionFailure("not supported") + self = .views + } + } +} + // MARK: - StatsServiceRemoteV2 Async Extensions -private extension StatsServiceRemoteV2 { - func getData( +private extension WordPressKit.StatsServiceRemoteV2 { + func getData( interval: DateInterval, - unit: StatsPeriodUnit, - limit: Int = 10 - ) async throws -> TimeStatsType where TimeStatsType: Sendable { + unit: WordPressKit.StatsPeriodUnit, + summarize: Bool? = nil, + 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: limit) { (data: TimeStatsType?, error: Error?) in - if let error = error { + getData(for: unit, unit: unit, startDate: interval.start, endingOn: interval.end, limit: 0, summarize: summarize, parameters: parameters) { (data: TimeStatsType?, error: Error?) in + if let error { continuation.resume(throwing: error) - } else if let data = data { + } else if let data { continuation.resume(returning: data) } else { continuation.resume(throwing: StatsServiceError.noData) @@ -244,12 +336,12 @@ private extension StatsServiceRemoteV2 { } } - func getInsight(limit: Int = 10) async throws -> InsightType where InsightType: Sendable { + func getInsight(limit: Int = 10) async throws -> InsightType where InsightType: Sendable { try await withCheckedThrowingContinuation { continuation in getInsight(limit: limit) { (insight: InsightType?, error: Error?) in - if let error = error { + if let error { continuation.resume(throwing: error) - } else if let insight = insight { + } else if let insight { continuation.resume(returning: insight) } else { continuation.resume(throwing: StatsServiceError.noData) @@ -261,9 +353,9 @@ private extension StatsServiceRemoteV2 { func getDetails(forPostID postID: Int) async throws -> StatsPostDetails { try await withCheckedThrowingContinuation { continuation in getDetails(forPostID: postID) { (details: StatsPostDetails?, error: Error?) in - if let error = error { + if let error { continuation.resume(throwing: error) - } else if let details = details { + } else if let details { continuation.resume(returning: details) } else { continuation.resume(throwing: StatsServiceError.noData) @@ -275,9 +367,9 @@ private extension StatsServiceRemoteV2 { func getInsight(limit: Int = 10) async throws -> StatsLastPostInsight { try await withCheckedThrowingContinuation { continuation in getInsight(limit: limit) { (insight: StatsLastPostInsight?, error: Error?) in - if let error = error { + if let error { continuation.resume(throwing: error) - } else if let insight = insight { + } else if let insight { continuation.resume(returning: insight) } else { continuation.resume(throwing: StatsServiceError.noData) @@ -286,24 +378,6 @@ private extension StatsServiceRemoteV2 { } } - func getData( - for period: StatsPeriodUnit, - endingOn: Date, - limit: Int = 10 - ) async throws -> StatsPublishedPostsTimeIntervalData { - try await withCheckedThrowingContinuation { continuation in - getData(for: period, endingOn: endingOn, limit: limit) { (data: StatsPublishedPostsTimeIntervalData?, error: Error?) in - if let error = error { - continuation.resume(throwing: error) - } else if let data = data { - continuation.resume(returning: data) - } else { - continuation.resume(throwing: StatsServiceError.noData) - } - } - } - } - func toggleSpamState(for referrerDomain: String, currentValue: Bool) async throws { try await withCheckedThrowingContinuation { continuation in toggleSpamState(for: referrerDomain, currentValue: currentValue, success: { @@ -314,7 +388,7 @@ private extension StatsServiceRemoteV2 { } } - func getData( + func getEmailSummaryData( quantity: Int, sortField: StatsEmailsSummaryData.SortField = .opens, sortOrder: StatsEmailsSummaryData.SortOrder = .descending diff --git a/Modules/Sources/JetpackStats/Services/StatsServiceProtocol.swift b/Modules/Sources/JetpackStats/Services/StatsServiceProtocol.swift index 276b0c0d4e8a..43e78f6273a5 100644 --- a/Modules/Sources/JetpackStats/Services/StatsServiceProtocol.swift +++ b/Modules/Sources/JetpackStats/Services/StatsServiceProtocol.swift @@ -1,7 +1,10 @@ import Foundation protocol StatsServiceProtocol: AnyObject, Sendable { - func getSiteStats(interval: DateInterval, granularity: DateRangeGranularity) async throws -> SiteStatsData - func getTopListData(_ dataType: TopListItemType, range: DateInterval, granularity: DateRangeGranularity) async throws -> TopListData + var supportedMetrics: [SiteMetric] { get } + var supportedItems: [TopListItemType] { get } + + func getSiteStats(interval: DateInterval, granularity: DateRangeGranularity) async throws -> SiteMetricsData + func getTopListData(_ dataType: TopListItemType, interval: DateInterval, granularity: DateRangeGranularity) async throws -> TopListData func getRealtimeTopListData(_ dataType: TopListItemType) async throws -> TopListData } diff --git a/Modules/Sources/JetpackStats/StatsContext.swift b/Modules/Sources/JetpackStats/StatsContext.swift index 04a0996d5b68..2bc1197c8958 100644 --- a/Modules/Sources/JetpackStats/StatsContext.swift +++ b/Modules/Sources/JetpackStats/StatsContext.swift @@ -1,6 +1,6 @@ import Foundation import SwiftUI -import WordPressKit +@preconcurrency import WordPressKit public struct StatsContext: Sendable { /// The reporting time zone (the time zone of the site). @@ -10,7 +10,7 @@ public struct StatsContext: Sendable { let formatters: StatsFormatters public init(timeZone: TimeZone, siteID: Int, api: WordPressComRestApi) { - self.init(timeZone: timeZone, service: StatsService(siteID: siteID, api: api, siteTimezone: timeZone)) + self.init(timeZone: timeZone, service: StatsService(siteID: siteID, api: api, timeZone: timeZone)) } init(timeZone: TimeZone, service: (any StatsServiceProtocol)) { diff --git a/Modules/Sources/JetpackStats/Strings.swift b/Modules/Sources/JetpackStats/Strings.swift index 590c0a4b666c..690a3beec28c 100644 --- a/Modules/Sources/JetpackStats/Strings.swift +++ b/Modules/Sources/JetpackStats/Strings.swift @@ -1,4 +1,5 @@ import Foundation +import WordPressShared enum Strings { static let stats = AppLocalizedString("jetpackStats.title", value: "Stats", comment: "Stats screen title") @@ -37,6 +38,7 @@ enum Strings { static let visitorsNow = AppLocalizedString("jetpackStats.siteMetrics.visitorsNow", value: "Visitors Now", comment: "Current active visitors metric") static let likes = AppLocalizedString("jetpackStats.siteMetrics.likes", value: "Likes", comment: "Site likes metric") static let comments = AppLocalizedString("jetpackStats.siteMetrics.comments", value: "Comments", comment: "Site comments metric") + static let posts = AppLocalizedString("jetpackStats.siteMetrics.posts", value: "Posts", comment: "Site posts metric") static let timeOnSite = AppLocalizedString("jetpackStats.siteMetrics.timeOnSite", value: "Time on Site", comment: "Time on site metric") static let bounceRate = AppLocalizedString("jetpackStats.siteMetrics.bounceRate", value: "Bounce Rate", comment: "Bounce rate metric") } @@ -77,6 +79,7 @@ enum Strings { static let lineChart = AppLocalizedString("jetpackStats.chart.lineChart", value: "Lines", comment: "Line chart type") static let barChart = AppLocalizedString("jetpackStats.chart.barChart", value: "Bars", comment: "Bar chart type") static let incompleteData = AppLocalizedString("jetpackStats.chart.incompleteData", value: "Might show incomplete data", comment: "Shown when current period data might be incomplete") + static let hourlyDataUnavailable = AppLocalizedString("jetpackStats.chart.hourlyDataNotAvailable", value: "Hourly data not available", comment: "Shown for metrics that don't support hourly data") } enum TopListTitles { @@ -84,7 +87,12 @@ enum Strings { static let mostVisitors = AppLocalizedString("jetpackStats.topList.mostVisitors", value: "Most Visitors", comment: "Title for items with most visitors") static let mostCommented = AppLocalizedString("jetpackStats.topList.mostCommented", value: "Most Commented", comment: "Title for most commented items") static let mostLiked = AppLocalizedString("jetpackStats.topList.mostLiked", value: "Most Liked", comment: "Title for most liked items") + static let mostPosts = AppLocalizedString("jetpackStats.topList.mostPosts", value: "Most Posts", comment: "Title for most posts (per author)") static let highestBounceRate = AppLocalizedString("jetpackStats.topList.highestBounceRate", value: "Highest Bounce Rate", comment: "Title for items with highest bounce rate") static let longestTimeOnSite = AppLocalizedString("jetpackStats.topList.longestTimeOnSite", value: "Longest Time on Site", comment: "Title for items with longest time on site") } + + enum Errors { + static let generic = AppLocalizedString("jetpackStats.chart.generitcError", value: "Something went wrong", comment: "Genertic error message") + } } diff --git a/Modules/Sources/JetpackStats/Utilities/AppLocalizedString.swift b/Modules/Sources/JetpackStats/Utilities/AppLocalizedString.swift deleted file mode 100644 index 42d411cab350..000000000000 --- a/Modules/Sources/JetpackStats/Utilities/AppLocalizedString.swift +++ /dev/null @@ -1,28 +0,0 @@ -import Foundation - -public func AppLocalizedString(_ key: String, tableName: String? = nil, value: String? = nil, comment: String) -> String { - Bundle.app.localizedString(forKey: key, value: value, table: nil) -} - -private extension Bundle { - /// Returns the `Bundle` for the host `.app`. - /// - /// - If this is called from code already located in the main app's bundle or from a Pod/Framework, - /// this will return the same as `Bundle.main`, aka the bundle of the app itself. - /// - If this is called from an App Extension (Widget, ShareExtension, etc), this will return the bundle of the - /// main app hosting said App Extension (while `Bundle.main` would return the App Extension itself) - /// - /// This is particularly useful to reference a resource or string bundled inside the app from an App Extension / Widget. - /// - /// - Note: - /// In the context of Unit Tests this will return the Test Harness (aka Test Host) app, since that is the app running said tests. - /// - static let app: Bundle = { - var url = Bundle.main.bundleURL - while url.pathExtension != "app" && url.lastPathComponent != "/" { - url.deleteLastPathComponent() - } - guard let appBundle = Bundle(url: url) else { fatalError("Unable to find the parent app bundle") } - return appBundle - }() -} diff --git a/Modules/Sources/JetpackStats/Utilities/Formatters/StatsDateFormatter.swift b/Modules/Sources/JetpackStats/Utilities/Formatters/StatsDateFormatter.swift index 8e1cf7cf4e59..0b7aa9cc31db 100644 --- a/Modules/Sources/JetpackStats/Utilities/Formatters/StatsDateFormatter.swift +++ b/Modules/Sources/JetpackStats/Utilities/Formatters/StatsDateFormatter.swift @@ -1,36 +1,96 @@ import Foundation -struct StatsDateFormatter { - var locale: Locale - var timeZone: TimeZone +struct StatsDateFormatter: Sendable { + enum Context { + case compact + case regular + } + + var locale: Locale { + didSet { + updateFormatters() + } + } + + var timeZone: TimeZone { + didSet { + updateFormatters() + } + } + + final class CachedFormatters: Sendable { + let hour: DateFormatter + + let compactDay: DateFormatter + let compactMonth: DateFormatter + + let regularDay: DateFormatter + let regularMonth: DateFormatter + + let year: DateFormatter + + let timeOffset: DateFormatter + + init(locale: Locale, timeZone: TimeZone) { + func makeFormatter(_ dateFormat: String) -> DateFormatter { + let formatter = DateFormatter() + formatter.locale = locale + formatter.timeZone = timeZone + formatter.dateFormat = dateFormat + return formatter + } + + hour = makeFormatter("h a") + + compactDay = makeFormatter("MMM d") + compactMonth = makeFormatter("MMM") + + regularDay = makeFormatter("EEEE, MMMM d") + regularMonth = makeFormatter("MMMM yyyy") + + year = makeFormatter("yyyy") + + timeOffset = makeFormatter("ZZZZ") + } + + func formatter(granularity: DateRangeGranularity, context: Context) -> DateFormatter { + switch context { + case .compact: + switch granularity { + case .hour: hour + case .day: compactDay + case .month: compactMonth + case .year: year + } + case .regular: + switch granularity { + case .hour: hour + case .day: regularDay + case .month: regularMonth + case .year: year + } + } + } + } + + private var formatters: CachedFormatters + + private mutating func updateFormatters() { + formatters = CachedFormatters(locale: locale, timeZone: timeZone) + } init(locale: Locale = .current, timeZone: TimeZone = .current) { self.locale = locale self.timeZone = timeZone + self.formatters = CachedFormatters(locale: locale, timeZone: timeZone) } - func formatDate(_ date: Date, granularity: DateRangeGranularity) -> String { - let formatter = DateFormatter() - formatter.locale = locale - formatter.timeZone = timeZone - - switch granularity { - case .hour: - formatter.dateFormat = "h a" // 3 AM - case .day: - formatter.dateFormat = "MMM d" - case .month: - formatter.dateFormat = "MMM" - case .year: - formatter.dateFormat = "yyyy" - } + func formatDate(_ date: Date, granularity: DateRangeGranularity, context: Context = .compact) -> String { + let formatter = formatters.formatter(granularity: granularity, context: context) return formatter.string(from: date) } var formattedTimeOffset: String { - let formatter = DateFormatter() - formatter.timeZone = timeZone - formatter.dateFormat = "ZZZZ" // "GMT-05:00" - return formatter.string(from: Date()) + formatters.timeOffset.string(from: .now) } } diff --git a/Modules/Sources/JetpackStats/Utilities/TrendViewModel.swift b/Modules/Sources/JetpackStats/Utilities/TrendViewModel.swift index 39e483efa116..63d128f5b4c3 100644 --- a/Modules/Sources/JetpackStats/Utilities/TrendViewModel.swift +++ b/Modules/Sources/JetpackStats/Utilities/TrendViewModel.swift @@ -87,7 +87,12 @@ struct TrendViewModel: Hashable { /// Formatted percentage string (shows "∞" for infinite change) /// - Example: "25%", "150.5%", or "∞" when previousValue was 0. var formattedPercentage: String { - guard let percentage else { return "∞" } + if currentValue == 0 && previousValue == 0 { + return "0" + } + guard let percentage else { + return "∞" + } return percentage.formatted(.percent.precision(.fractionLength(0...1))) } } diff --git a/Modules/Sources/JetpackStats/Views/ChartValueTooltipView.swift b/Modules/Sources/JetpackStats/Views/ChartValueTooltipView.swift index c0c4803ed0c9..44c5461f810d 100644 --- a/Modules/Sources/JetpackStats/Views/ChartValueTooltipView.swift +++ b/Modules/Sources/JetpackStats/Views/ChartValueTooltipView.swift @@ -10,7 +10,11 @@ struct ChartValueTooltipView: View { private var formattedDate: String? { guard let date = currentPoint?.date ?? previousPoint?.date else { return nil } - return context.formatters.date.formatDate(date, granularity: granularity) + return formattedDate(date) + } + + private func formattedDate(_ date: Date) -> String { + context.formatters.date.formatDate(date, granularity: granularity, context: .regular) } private var trend: TrendViewModel? { diff --git a/Modules/Sources/JetpackStats/Views/SimpleErrorView.swift b/Modules/Sources/JetpackStats/Views/SimpleErrorView.swift index 8d0b3fb12c08..de0d73a48956 100644 --- a/Modules/Sources/JetpackStats/Views/SimpleErrorView.swift +++ b/Modules/Sources/JetpackStats/Views/SimpleErrorView.swift @@ -1,10 +1,18 @@ import SwiftUI struct SimpleErrorView: View { - let error: Error + let message: String + + init(message: String) { + self.message = message + } + + init(error: Error) { + self.message = error.localizedDescription + } var body: some View { - Text(error.localizedDescription) + Text(message) .font(.subheadline.weight(.medium)) .multilineTextAlignment(.center) .frame(maxWidth: 300) diff --git a/Modules/Sources/JetpackStats/Views/StatsTabBar.swift b/Modules/Sources/JetpackStats/Views/StatsTabBar.swift index ad1a8846024a..dc56e0ecfe82 100644 --- a/Modules/Sources/JetpackStats/Views/StatsTabBar.swift +++ b/Modules/Sources/JetpackStats/Views/StatsTabBar.swift @@ -32,6 +32,7 @@ struct StatsTabBar: View { } Divider() } + .padding(.top, 8) .background { backgroundView } diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListPostRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListPostRowView.swift index 33e24e6150d6..46e9936b75ac 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListPostRowView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListPostRowView.swift @@ -1,4 +1,5 @@ import SwiftUI +import WordPressShared struct TopListPostRowView: View { let item: TopListData.Post @@ -11,11 +12,19 @@ struct TopListPostRowView: View { .foregroundColor(.primary) .lineLimit(1) - if showDetails, let author = item.author { - Text(verbatim: author) - .font(.caption) - .foregroundColor(.secondary) - .lineLimit(1) + if showDetails { + if let author = item.author { + Text(verbatim: author) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } + if let date = item.date { + Text(verbatim: date.toMediumString()) + .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 e86b07d02adc..f624823bb1f5 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift @@ -31,7 +31,7 @@ struct TopListItemView: View { // Metrics view TopListMetricsView( currentValue: currentItem.metrics[metric] ?? 0, - previousValue: previousItem?.metrics[metric], + previousValue: previousItem?.metrics[metric] ?? 0, metric: metric, showDetails: showDetails ) diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift index 02bc96dec3b0..89cb196c4221 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift @@ -3,67 +3,21 @@ import SwiftUI struct TopListItemsView: View { let data: TopListChartData let itemLimit: Int - let showDetails: Bool - let showMoreButton: Bool - let onShowMore: (() -> Void)? + var showDetails = true var body: some View { - VStack(spacing: 0) { - VStack(spacing: Constants.step1 / 2) { - ForEach(data.items.prefix(itemLimit), id: \.current.id) { item in - TopListItemView( - currentItem: item.current, - previousItem: item.previous, - metric: data.metric, - maxValue: data.maxValue, - showDetails: showDetails - ) - .transition(.opacity) - } - // Add empty rows if needed to maintain consistent height - let itemsToShow = data.items.prefix(itemLimit).count - if itemsToShow < itemLimit { - ForEach(itemsToShow.. 0) diff --git a/Modules/Tests/JetpackStatsTests/StatsDateFormatterTests.swift b/Modules/Tests/JetpackStatsTests/StatsDateFormatterTests.swift index 94f93652a5f6..75ccbd06ef05 100644 --- a/Modules/Tests/JetpackStatsTests/StatsDateFormatterTests.swift +++ b/Modules/Tests/JetpackStatsTests/StatsDateFormatterTests.swift @@ -9,7 +9,7 @@ struct StatsDateFormatterTests { timeZone: .eastern ) - @Test func hourFormatting() { + @Test func hourFormattingCompact() { let date = Date("2025-03-15T14:00:00-03:00") let result = formatter.formatDate(date, granularity: .hour) #expect(result == "2 PM") @@ -23,21 +23,49 @@ struct StatsDateFormatterTests { #expect(noonResult == "12 PM") } - @Test func dayFormatting() { + @Test func hourFormattingRegular() { + let date = Date("2025-03-15T14:00:00-03:00") + let result = formatter.formatDate(date, granularity: .hour, context: .regular) + #expect(result == "2 PM") + + let midnight = Date("2025-03-15T00:00:00-03:00") + let midnightResult = formatter.formatDate(midnight, granularity: .hour, context: .regular) + #expect(midnightResult == "12 AM") + } + + @Test func dayFormattingCompact() { let date = Date("2025-03-15T14:00:00-03:00") let result = formatter.formatDate(date, granularity: .day) #expect(result == "Mar 15") } - @Test func monthFormatting() { + @Test func dayFormattingRegular() { + let date = Date("2025-03-15T14:00:00-03:00") + let result = formatter.formatDate(date, granularity: .day, context: .regular) + #expect(result == "Saturday, March 15") + } + + @Test func monthFormattingCompact() { let date = Date("2025-03-15T14:00:00-03:00") let result = formatter.formatDate(date, granularity: .month) #expect(result == "Mar") } - @Test func yearFormatting() { + @Test func monthFormattingRegular() { + let date = Date("2025-03-15T14:00:00-03:00") + let result = formatter.formatDate(date, granularity: .month, context: .regular) + #expect(result == "March 2025") + } + + @Test func yearFormattingCompact() { let date = Date("2025-03-15T14:00:00-03:00") let result = formatter.formatDate(date, granularity: .year) #expect(result == "2025") } + + @Test func yearFormattingRegular() { + let date = Date("2025-03-15T14:00:00-03:00") + let result = formatter.formatDate(date, granularity: .year, context: .regular) + #expect(result == "2025") + } } diff --git a/WordPress/Classes/Utility/ContentCoordinator.swift b/WordPress/Classes/Utility/ContentCoordinator.swift index 18c2dbf84d98..31c6e0c7a317 100644 --- a/WordPress/Classes/Utility/ContentCoordinator.swift +++ b/WordPress/Classes/Utility/ContentCoordinator.swift @@ -65,15 +65,8 @@ struct DefaultContentCoordinator: ContentCoordinator { setTimePeriodForStatsURLIfPossible(url) } - if FeatureFlag.newStats.enabled { - let statsVC = StatsHostingViewController(blog: blog) - statsVC.hidesBottomBarWhenPushed = true - controller?.navigationController?.pushViewController(statsVC, animated: true) - } else { - let statsViewController = StatsViewController() - statsViewController.blog = blog - controller?.navigationController?.pushViewController(statsViewController, animated: true) - } + let statsVC = StatsHostingViewController.makeStatsViewController(for: blog) + controller?.navigationController?.pushViewController(statsVC, animated: true) } private func setTimePeriodForStatsURLIfPossible(_ url: URL) { diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/DashboardQuickActionsCardCell.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/DashboardQuickActionsCardCell.swift index ecf1c021e113..bc630b71da67 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/DashboardQuickActionsCardCell.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/DashboardQuickActionsCardCell.swift @@ -108,7 +108,8 @@ final class DashboardQuickActionsCardCell: UICollectionViewCell, Reusable, UITab parentViewController.show(controller, sender: nil) case .stats: trackQuickActionsEvent(.statsAccessed, blog: blog) - StatsViewController.show(for: blog, from: parentViewController) + let statsVC = StatsHostingViewController.makeStatsViewController(for: blog) + parentViewController.show(statsVC, sender: nil) case .more: let viewController = BlogDetailsViewController() viewController.isScrollEnabled = true diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift index 34dae7c27852..0fffd72ed1ec 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift @@ -265,20 +265,7 @@ extension BlogDetailsViewController { guard JetpackFeaturesRemovalCoordinator.jetpackFeaturesEnabled() else { return MovedToJetpackViewController(source: .stats) } - - // Use new stats if feature flag is enabled - if FeatureFlag.newStats.enabled { - let statsVC = StatsHostingViewController(blog: blog) - statsVC.hidesBottomBarWhenPushed = true - statsVC.navigationItem.largeTitleDisplayMode = .never - return statsVC - } else { - let statsVC = StatsViewController() - statsVC.blog = blog - statsVC.hidesBottomBarWhenPushed = true - statsVC.navigationItem.largeTitleDisplayMode = .never - return statsVC - } + return StatsHostingViewController.makeStatsViewController(for: blog) } @objc(showDomainsFromSource:) diff --git a/WordPress/Classes/ViewRelated/Stats/Charts/Charts+Support.swift b/WordPress/Classes/ViewRelated/Stats/Charts/Charts+Support.swift index 2b83b908098e..f0bcbc013e9c 100644 --- a/WordPress/Classes/ViewRelated/Stats/Charts/Charts+Support.swift +++ b/WordPress/Classes/ViewRelated/Stats/Charts/Charts+Support.swift @@ -116,6 +116,8 @@ enum LineChartAnalyticsPropertyGranularityValue: String, CaseIterable { extension StatsPeriodUnit { var analyticsGranularity: BarChartAnalyticsPropertyGranularityValue { switch self { + case .hour: + fatalError("unsupported") case .day: return .days case .week: @@ -129,6 +131,8 @@ extension StatsPeriodUnit { var analyticsGranularityLine: LineChartAnalyticsPropertyGranularityValue { switch self { + case .hour: + fatalError("unsupported") case .day: return .days case .week: diff --git a/WordPress/Classes/ViewRelated/Stats/Helpers/StatsDataHelper.swift b/WordPress/Classes/ViewRelated/Stats/Helpers/StatsDataHelper.swift index 642ec97d11a1..7bab4357e539 100644 --- a/WordPress/Classes/ViewRelated/Stats/Helpers/StatsDataHelper.swift +++ b/WordPress/Classes/ViewRelated/Stats/Helpers/StatsDataHelper.swift @@ -247,6 +247,8 @@ extension StatsPeriodUnit { var dateFormatTemplate: String { switch self { + case .hour: + fatalError("unsupported") case .day: return "MMM d, yyyy" case .week: @@ -260,6 +262,8 @@ extension StatsPeriodUnit { var calendarComponent: Calendar.Component { switch self { + case .hour: + return .hour case .day: return .day case .week: @@ -273,6 +277,7 @@ extension StatsPeriodUnit { var description: String { switch self { + case .hour: return "hour" case .day: return "day" case .week: return "week" case .month: return "month" diff --git a/WordPress/Classes/ViewRelated/Stats/Helpers/StatsPeriodHelper.swift b/WordPress/Classes/ViewRelated/Stats/Helpers/StatsPeriodHelper.swift index 9dfac8976acd..7ce10ac05b8a 100644 --- a/WordPress/Classes/ViewRelated/Stats/Helpers/StatsPeriodHelper.swift +++ b/WordPress/Classes/ViewRelated/Stats/Helpers/StatsPeriodHelper.swift @@ -20,6 +20,8 @@ class StatsPeriodHelper { oldestDate = oldestDate.normalizedDate() switch period { + case .hour: + fatalError("unsupported") case .day: return date > oldestDate case .week: @@ -47,6 +49,8 @@ class StatsPeriodHelper { let date = dateIn.normalizedDate() switch period { + case .hour: + fatalError("unsupported") case .day: return date < currentDate.normalizedDate() case .week: @@ -70,6 +74,8 @@ class StatsPeriodHelper { func endDate(from intervalStartDate: Date, period: StatsPeriodUnit) -> Date { switch period { + case .hour: + fatalError("unsupported") case .day: return intervalStartDate.normalizedDate() case .week: @@ -103,6 +109,9 @@ class StatsPeriodHelper { } switch unit { + case .hour: + fatalError("unsupported") + case .day: return adjustedDate.normalizedDate() diff --git a/WordPress/Classes/ViewRelated/Stats/Shared Views/Date Chooser/SiteStatsTableHeaderView.swift b/WordPress/Classes/ViewRelated/Stats/Shared Views/Date Chooser/SiteStatsTableHeaderView.swift index 2472f7050162..2508a9a73992 100644 --- a/WordPress/Classes/ViewRelated/Stats/Shared Views/Date Chooser/SiteStatsTableHeaderView.swift +++ b/WordPress/Classes/ViewRelated/Stats/Shared Views/Date Chooser/SiteStatsTableHeaderView.swift @@ -202,6 +202,8 @@ private extension SiteStatsTableHeaderView { dateFormatter.setLocalizedDateFormatFromTemplate(period.dateFormatTemplate) switch period { + case .hour: + fatalError("unsupported") case .day, .month, .year: return (dateFormatter.string(from: date), nil) case .week: diff --git a/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift b/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift index 04061639f833..42b560583f92 100644 --- a/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift +++ b/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift @@ -17,6 +17,21 @@ class StatsHostingViewController: UIViewController { super.init(nibName: nil, bundle: nil) } + static func makeStatsViewController(for blog: Blog) -> UIViewController { + guard FeatureFlag.newStats.enabled else { + let statsVC = StatsViewController() + statsVC.blog = blog + statsVC.hidesBottomBarWhenPushed = true + statsVC.navigationItem.largeTitleDisplayMode = .never + return statsVC + } + + let statsVC = StatsHostingViewController(blog: blog) + statsVC.hidesBottomBarWhenPushed = true + statsVC.navigationItem.largeTitleDisplayMode = .never + return statsVC + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } diff --git a/WordPress/Classes/ViewRelated/Stats/StatsViewController.h b/WordPress/Classes/ViewRelated/Stats/StatsViewController.h index c8178651f2c7..833593145927 100644 --- a/WordPress/Classes/ViewRelated/Stats/StatsViewController.h +++ b/WordPress/Classes/ViewRelated/Stats/StatsViewController.h @@ -7,6 +7,4 @@ @property (nonatomic, weak, nullable) Blog *blog; @property (nonatomic, copy, nullable) void (^dismissBlock)(void); -+ (void)showForBlog:(nonnull Blog *)blog from:(nonnull UIViewController *)controller; - @end diff --git a/WordPress/Classes/ViewRelated/Stats/StatsViewController.m b/WordPress/Classes/ViewRelated/Stats/StatsViewController.m index eb08c1e020da..27a63f92a15b 100644 --- a/WordPress/Classes/ViewRelated/Stats/StatsViewController.m +++ b/WordPress/Classes/ViewRelated/Stats/StatsViewController.m @@ -25,15 +25,6 @@ - (instancetype)init return self; } -+ (void)showForBlog:(Blog *)blog from:(UIViewController *)controller -{ - StatsViewController *statsController = [StatsViewController new]; - statsController.blog = blog; - statsController.hidesBottomBarWhenPushed = YES; - statsController.navigationItem.largeTitleDisplayMode = UINavigationItemLargeTitleDisplayModeNever; - [controller.navigationController pushViewController:statsController animated:YES]; -} - - (void)viewDidLoad { [super viewDidLoad]; diff --git a/WordPress/Classes/ViewRelated/Stats/StatsViewController.swift b/WordPress/Classes/ViewRelated/Stats/StatsViewController.swift index b36e35182459..d9920058fac5 100644 --- a/WordPress/Classes/ViewRelated/Stats/StatsViewController.swift +++ b/WordPress/Classes/ViewRelated/Stats/StatsViewController.swift @@ -14,16 +14,4 @@ extension StatsViewController { controller.view.translatesAutoresizingMaskIntoConstraints = false controller.view.pinEdges() } - - /// Shows the stats view controller for the given blog, using the new stats UI if the feature flag is enabled - @objc public static func show(for blog: Blog, from viewController: UIViewController) { - if FeatureFlag.newStats.enabled { - let statsVC = StatsHostingViewController(blog: blog) - statsVC.hidesBottomBarWhenPushed = true - viewController.navigationController?.pushViewController(statsVC, animated: true) - } else { - // Use the existing Objective-C method - show(for: blog, from: viewController) - } - } } diff --git a/WordPress/Classes/ViewRelated/Stats/Traffic/StatsTrafficDatePickerView.swift b/WordPress/Classes/ViewRelated/Stats/Traffic/StatsTrafficDatePickerView.swift index d5f1d034284f..8576810d80c0 100644 --- a/WordPress/Classes/ViewRelated/Stats/Traffic/StatsTrafficDatePickerView.swift +++ b/WordPress/Classes/ViewRelated/Stats/Traffic/StatsTrafficDatePickerView.swift @@ -90,6 +90,8 @@ struct StatsTrafficDatePickerView: View { private extension StatsPeriodUnit { var label: String { switch self { + case .hour: + fatalError("unsupported") case .day: return NSLocalizedString("stats.traffic.days", value: "Days", comment: "The label for the option to show Stats Traffic chart for Days.") case .week: diff --git a/WordPress/Classes/ViewRelated/Stats/Traffic/StatsTrafficDatePickerViewModel.swift b/WordPress/Classes/ViewRelated/Stats/Traffic/StatsTrafficDatePickerViewModel.swift index bfb508ea09e1..f694a2034b80 100644 --- a/WordPress/Classes/ViewRelated/Stats/Traffic/StatsTrafficDatePickerViewModel.swift +++ b/WordPress/Classes/ViewRelated/Stats/Traffic/StatsTrafficDatePickerViewModel.swift @@ -73,6 +73,8 @@ private extension StatsPeriodUnit { var dateFormatter: DateFormatter { let format: String switch self { + case .hour: + fatalError("unsupported") case .day: format = "MMMM d, yyyy" case .week: @@ -89,6 +91,8 @@ private extension StatsPeriodUnit { var event: WPAnalyticsStat { switch self { + case .hour: + fatalError("unsupported") case .day: return .statsPeriodDaysAccessed case .week: From c8a52f8fc2771baedcbcab4fbea20285dbd4c00c Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Mon, 21 Jul 2025 16:51:47 -0400 Subject: [PATCH 003/349] Add empty state view to TopListCard --- .../JetpackStats/Cards/TopListCard.swift | 24 ++++++++++++------- Modules/Sources/JetpackStats/Strings.swift | 1 + 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/Modules/Sources/JetpackStats/Cards/TopListCard.swift b/Modules/Sources/JetpackStats/Cards/TopListCard.swift index cfb1c7347370..c34d179ae3fb 100644 --- a/Modules/Sources/JetpackStats/Cards/TopListCard.swift +++ b/Modules/Sources/JetpackStats/Cards/TopListCard.swift @@ -103,15 +103,13 @@ struct TopListCard: View { topListItemsView(data: mockData) .redacted(reason: .placeholder) } else if let data = viewModel.matchedData { - topListItemsView(data: data) + if data.items.isEmpty { + makeEmptyStateView(message: Strings.Chart.empty) + } else { + topListItemsView(data: data) + } } else { - topListItemsView(data: mockData) - .redacted(reason: .placeholder) - .grayscale(1) - .opacity(0.33) - .overlay { - SimpleErrorView(message: viewModel.loadingError?.localizedDescription ?? Strings.Errors.generic) - } + makeEmptyStateView(message: viewModel.loadingError?.localizedDescription ?? Strings.Errors.generic) } } } @@ -148,6 +146,16 @@ struct TopListCard: View { .tint(Color.secondary.opacity(0.8)) } + private func makeEmptyStateView(message: String) -> some View { + topListItemsView(data: mockData) + .redacted(reason: .placeholder) + .grayscale(1) + .opacity(0.25) + .overlay { + SimpleErrorView(message: message) + } + } + private var mockData: TopListChartData { TopListChartData.mock(for: viewModel.selection.item, metric: viewModel.selection.metric, itemCount: itemLimit) } diff --git a/Modules/Sources/JetpackStats/Strings.swift b/Modules/Sources/JetpackStats/Strings.swift index 690a3beec28c..4d8fd28370fc 100644 --- a/Modules/Sources/JetpackStats/Strings.swift +++ b/Modules/Sources/JetpackStats/Strings.swift @@ -80,6 +80,7 @@ enum Strings { static let barChart = AppLocalizedString("jetpackStats.chart.barChart", value: "Bars", comment: "Bar chart type") static let incompleteData = AppLocalizedString("jetpackStats.chart.incompleteData", value: "Might show incomplete data", comment: "Shown when current period data might be incomplete") static let hourlyDataUnavailable = AppLocalizedString("jetpackStats.chart.hourlyDataNotAvailable", value: "Hourly data not available", comment: "Shown for metrics that don't support hourly data") + static let empty = AppLocalizedString("jetpackStats.chart.dataEmpty", value: "Not data available", comment: "Shown for empty states") } enum TopListTitles { From d8aa9e6996caf8ed2db6292931e3b766d9b2dc83 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Mon, 21 Jul 2025 16:56:23 -0400 Subject: [PATCH 004/349] Configure available metrics based on the service --- Modules/Sources/JetpackStats/Cards/TopListCard.swift | 12 +++++++++--- .../JetpackStats/Services/Data/SiteMetric.swift | 2 +- .../JetpackStats/Services/Data/TopListItemType.swift | 12 +----------- .../Services/Mocks/MockStatsService.swift | 10 ++++++++++ .../Sources/JetpackStats/Services/StatsService.swift | 10 ++++++++++ .../JetpackStats/Services/StatsServiceProtocol.swift | 2 ++ 6 files changed, 33 insertions(+), 15 deletions(-) diff --git a/Modules/Sources/JetpackStats/Cards/TopListCard.swift b/Modules/Sources/JetpackStats/Cards/TopListCard.swift index c34d179ae3fb..d75bbab4c172 100644 --- a/Modules/Sources/JetpackStats/Cards/TopListCard.swift +++ b/Modules/Sources/JetpackStats/Cards/TopListCard.swift @@ -40,8 +40,10 @@ struct TopListCard: View { Button { var selection = viewModel.selection selection.item = item - if !item.availableMetrics.contains(selection.metric), - let metric = item.availableMetrics.first { + + let supportedMetric = getSupportedMetrics(for: item) + if !supportedMetric.contains(selection.metric), + let metric = supportedMetric.first { selection.metric = metric } viewModel.selection = selection @@ -58,7 +60,7 @@ struct TopListCard: View { Spacer() Menu { - ForEach(viewModel.selection.item.availableMetrics) { metric in + ForEach(getSupportedMetrics(for: viewModel.selection.item)) { metric in Button { viewModel.selection.metric = metric } label: { @@ -73,6 +75,10 @@ struct TopListCard: View { } } + private func getSupportedMetrics(for item: TopListItemType) -> [SiteMetric] { + context.service.getSupportedMetrics(for: item) + } + private var moreMenu: some View { Menu { moreMenuContent diff --git a/Modules/Sources/JetpackStats/Services/Data/SiteMetric.swift b/Modules/Sources/JetpackStats/Services/Data/SiteMetric.swift index a20d02f8be32..1d169b0cf674 100644 --- a/Modules/Sources/JetpackStats/Services/Data/SiteMetric.swift +++ b/Modules/Sources/JetpackStats/Services/Data/SiteMetric.swift @@ -1,6 +1,6 @@ import SwiftUI -enum SiteMetric: CaseIterable, Identifiable { +enum SiteMetric: CaseIterable, Identifiable, Sendable { case views case visitors case likes diff --git a/Modules/Sources/JetpackStats/Services/Data/TopListItemType.swift b/Modules/Sources/JetpackStats/Services/Data/TopListItemType.swift index 3a053a277676..ee48ee4c412f 100644 --- a/Modules/Sources/JetpackStats/Services/Data/TopListItemType.swift +++ b/Modules/Sources/JetpackStats/Services/Data/TopListItemType.swift @@ -1,6 +1,6 @@ import SwiftUI -enum TopListItemType: Identifiable, CaseIterable { +enum TopListItemType: Identifiable, CaseIterable, Sendable { case postsAndPages case posts case pages @@ -35,16 +35,6 @@ enum TopListItemType: Identifiable, CaseIterable { } } - var availableMetrics: [SiteMetric] { - switch self { - case .postsAndPages, .posts, .pages: [.views, .visitors, .comments, .likes] - case .referrers: [.views, .views] - case .locations: [.views, .views] - case .authors: [.views, .comments, .likes] - case .externalLinks: [.views, .visitors] - } - } - func getTitle(for metric: SiteMetric) -> String { switch metric { case .views: Strings.TopListTitles.mostViewed diff --git a/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift b/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift index a89968a1e1e0..6e4c8636a049 100644 --- a/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift +++ b/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift @@ -9,6 +9,16 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { let supportedMetrics = SiteMetric.allCases let supportedItems = TopListItemType.allCases + nonisolated func getSupportedMetrics(for item: TopListItemType) -> [SiteMetric] { + switch item { + case .postsAndPages, .posts, .pages: [.views, .visitors, .comments, .likes] + case .referrers: [.views, .visitors] + case .locations: [.views, .visitors] + case .authors: [.views, .comments, .likes] + case .externalLinks: [.views, .visitors] + } + } + /// - parameter timeZone: The reporting time zone of a site. init(timeZone: TimeZone = .current) { var calendar = Calendar.current diff --git a/Modules/Sources/JetpackStats/Services/StatsService.swift b/Modules/Sources/JetpackStats/Services/StatsService.swift index 602cda68bf01..b1f7eb00e992 100644 --- a/Modules/Sources/JetpackStats/Services/StatsService.swift +++ b/Modules/Sources/JetpackStats/Services/StatsService.swift @@ -23,6 +23,16 @@ actor StatsService: StatsServiceProtocol { .postsAndPages ] + nonisolated func getSupportedMetrics(for item: TopListItemType) -> [SiteMetric] { + switch item { + case .postsAndPages, .posts, .pages: [.views] + case .referrers: [.views] + case .locations: [.views] + case .authors: [.views] + case .externalLinks: [.views] + } + } + init(siteID: Int, api: WordPressComRestApi, timeZone: TimeZone) { self.siteID = siteID self.api = api diff --git a/Modules/Sources/JetpackStats/Services/StatsServiceProtocol.swift b/Modules/Sources/JetpackStats/Services/StatsServiceProtocol.swift index 43e78f6273a5..0ae143cb5815 100644 --- a/Modules/Sources/JetpackStats/Services/StatsServiceProtocol.swift +++ b/Modules/Sources/JetpackStats/Services/StatsServiceProtocol.swift @@ -4,6 +4,8 @@ protocol StatsServiceProtocol: AnyObject, Sendable { var supportedMetrics: [SiteMetric] { get } var supportedItems: [TopListItemType] { get } + func getSupportedMetrics(for item: TopListItemType) -> [SiteMetric] + func getSiteStats(interval: DateInterval, granularity: DateRangeGranularity) async throws -> SiteMetricsData func getTopListData(_ dataType: TopListItemType, interval: DateInterval, granularity: DateRangeGranularity) async throws -> TopListData func getRealtimeTopListData(_ dataType: TopListItemType) async throws -> TopListData From 15b40319fca4f1018b46292df361b1bc67285428 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Mon, 21 Jul 2025 17:03:55 -0400 Subject: [PATCH 005/349] Pass metric when loading top list data --- .../Cards/TopListCardViewModel.swift | 2 + .../Services/Mocks/MockStatsService.swift | 6 +-- .../JetpackStats/Services/StatsService.swift | 44 +++++++++---------- .../Services/StatsServiceProtocol.swift | 4 +- 4 files changed, 29 insertions(+), 27 deletions(-) diff --git a/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift b/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift index e6c9c1291afc..561782ecfd76 100644 --- a/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift +++ b/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift @@ -117,11 +117,13 @@ final class TopListCardViewModel: ObservableObject, TrafficCardViewModel { // Fetch both current and previous period data concurrently async let currentTask = service.getTopListData( selection.item, + metric: selection.metric, interval: dateRange.dateInterval, granularity: granularity ) async let previousTask = service.getTopListData( selection.item, + metric: selection.metric, interval: dateRange.effectiveComparisonInterval, granularity: granularity ) diff --git a/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift b/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift index 6e4c8636a049..293c83e4f020 100644 --- a/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift +++ b/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift @@ -55,11 +55,11 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { return SiteMetricsData(total: total, metrics: output) } - func getTopListData(_ dataType: TopListItemType, interval: DateInterval, granularity: DateRangeGranularity) async throws -> TopListData { + func getTopListData(_ item: TopListItemType, metric: SiteMetric, interval: DateInterval, granularity: DateRangeGranularity) async throws -> TopListData { await generateDataIfNeeded() - guard let typeData = dailyTopListData[dataType] else { - fatalError("data not configured for data type: \(dataType)") + guard let typeData = dailyTopListData[item] else { + fatalError("data not configured for data type: \(item)") } // Filter data within the date range diff --git a/Modules/Sources/JetpackStats/Services/StatsService.swift b/Modules/Sources/JetpackStats/Services/StatsService.swift index b1f7eb00e992..e69c91335da5 100644 --- a/Modules/Sources/JetpackStats/Services/StatsService.swift +++ b/Modules/Sources/JetpackStats/Services/StatsService.swift @@ -67,9 +67,9 @@ actor StatsService: StatsServiceProtocol { } } - func getTopListData(_ item: TopListItemType, interval: DateInterval, granularity: DateRangeGranularity) async throws -> TopListData { + func getTopListData(_ item: TopListItemType, metric: SiteMetric, interval: DateInterval, granularity: DateRangeGranularity) async throws -> TopListData { do { - return try await _getTopListData(item, interval: interval, granularity: granularity) + return try await _getTopListData(item, metric: metric, interval: interval, granularity: granularity) } catch { // A workaround for an issue where `/stats` return `"summary": null` // when there are no recoreded periods (happens when the entire requested @@ -82,7 +82,7 @@ actor StatsService: StatsServiceProtocol { } } - private func _getTopListData(_ item: TopListItemType, interval: DateInterval, granularity: DateRangeGranularity) async throws -> TopListData { + private func _getTopListData(_ item: TopListItemType, metric: SiteMetric, interval: DateInterval, granularity: DateRangeGranularity) async throws -> TopListData { func getData( _ type: T.Type, @@ -95,14 +95,21 @@ actor StatsService: StatsServiceProtocol { switch item { case .postsAndPages: - let data = try await getData(StatsTopPostsTimeIntervalData.self) - return mapPostsToTopListData(data) + switch metric { + case .views: + let data = try await getData(StatsTopPostsTimeIntervalData.self) + return mapPostsToTopListData(data) + case .comments: + fatalError() + default: + throw StatsServiceError.unavailable + } case .pages: - throw StatsServiceError.notImplemented + throw StatsServiceError.unavailable case .posts: - throw StatsServiceError.notImplemented + throw StatsServiceError.unavailable case .referrers: let data = try await getData(StatsTopReferrersTimeIntervalData.self) @@ -117,7 +124,7 @@ actor StatsService: StatsServiceProtocol { return mapAuthorsToTopListData(data) case .externalLinks: - throw StatsServiceError.notImplemented + throw StatsServiceError.unavailable } } @@ -267,19 +274,12 @@ actor StatsService: StatsServiceProtocol { } } -// MARK: - Custom Errors - enum StatsServiceError: LocalizedError { - case noData - case notImplemented + case unknown + case unavailable var errorDescription: String? { - switch self { - case .noData: - return "No data received from the server" - case .notImplemented: - return "Not implemented" - } + Strings.Errors.generic } } @@ -340,7 +340,7 @@ private extension WordPressKit.StatsServiceRemoteV2 { } else if let data { continuation.resume(returning: data) } else { - continuation.resume(throwing: StatsServiceError.noData) + continuation.resume(throwing: StatsServiceError.unknown) } } } @@ -354,7 +354,7 @@ private extension WordPressKit.StatsServiceRemoteV2 { } else if let insight { continuation.resume(returning: insight) } else { - continuation.resume(throwing: StatsServiceError.noData) + continuation.resume(throwing: StatsServiceError.unknown) } } } @@ -368,7 +368,7 @@ private extension WordPressKit.StatsServiceRemoteV2 { } else if let details { continuation.resume(returning: details) } else { - continuation.resume(throwing: StatsServiceError.noData) + continuation.resume(throwing: StatsServiceError.unknown) } } } @@ -382,7 +382,7 @@ private extension WordPressKit.StatsServiceRemoteV2 { } else if let insight { continuation.resume(returning: insight) } else { - continuation.resume(throwing: StatsServiceError.noData) + continuation.resume(throwing: StatsServiceError.unknown) } } } diff --git a/Modules/Sources/JetpackStats/Services/StatsServiceProtocol.swift b/Modules/Sources/JetpackStats/Services/StatsServiceProtocol.swift index 0ae143cb5815..a4f569e4bf93 100644 --- a/Modules/Sources/JetpackStats/Services/StatsServiceProtocol.swift +++ b/Modules/Sources/JetpackStats/Services/StatsServiceProtocol.swift @@ -7,6 +7,6 @@ protocol StatsServiceProtocol: AnyObject, Sendable { func getSupportedMetrics(for item: TopListItemType) -> [SiteMetric] func getSiteStats(interval: DateInterval, granularity: DateRangeGranularity) async throws -> SiteMetricsData - func getTopListData(_ dataType: TopListItemType, interval: DateInterval, granularity: DateRangeGranularity) async throws -> TopListData - func getRealtimeTopListData(_ dataType: TopListItemType) async throws -> TopListData + func getTopListData(_ item: TopListItemType, metric: SiteMetric, interval: DateInterval, granularity: DateRangeGranularity) async throws -> TopListData + func getRealtimeTopListData(_ item: TopListItemType) async throws -> TopListData } From b44a47701b93f7d4c89c1fcbd10c85e658624fd0 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Mon, 21 Jul 2025 17:46:52 -0400 Subject: [PATCH 006/349] Add support for additional post types --- .../JetpackStats/Services/StatsService.swift | 45 ++++++++++++++++--- 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/Modules/Sources/JetpackStats/Services/StatsService.swift b/Modules/Sources/JetpackStats/Services/StatsService.swift index e69c91335da5..62fd5a02c810 100644 --- a/Modules/Sources/JetpackStats/Services/StatsService.swift +++ b/Modules/Sources/JetpackStats/Services/StatsService.swift @@ -20,7 +20,7 @@ actor StatsService: StatsServiceProtocol { ] let supportedItems: [TopListItemType] = [ - .postsAndPages + .postsAndPages, .posts, .pages, .referrers, .locations, .authors, .externalLinks ] nonisolated func getSupportedMetrics(for item: TopListItemType) -> [SiteMetric] { @@ -106,10 +106,22 @@ actor StatsService: StatsServiceProtocol { } case .pages: - throw StatsServiceError.unavailable + switch metric { + case .views: + let data = try await getData(StatsTopPostsTimeIntervalData.self) + return mapPostsToTopListData(data, filterKind: .page) + default: + throw StatsServiceError.unavailable + } case .posts: - throw StatsServiceError.unavailable + switch metric { + case .views: + let data = try await getData(StatsTopPostsTimeIntervalData.self) + return mapPostsToTopListData(data, filterKind: .post) + default: + throw StatsServiceError.unavailable + } case .referrers: let data = try await getData(StatsTopReferrersTimeIntervalData.self) @@ -124,7 +136,13 @@ actor StatsService: StatsServiceProtocol { return mapAuthorsToTopListData(data) case .externalLinks: - throw StatsServiceError.unavailable + switch metric { + case .views: + let data = try await getData(StatsTopClicksTimeIntervalData.self) + return mapClicksToTopListData(data) + default: + throw StatsServiceError.unavailable + } } } @@ -196,13 +214,15 @@ actor StatsService: StatsServiceProtocol { return SiteMetricsData(total: total, metrics: metrics) } - private func mapPostsToTopListData(_ data: StatsTopPostsTimeIntervalData) -> TopListData { + private func mapPostsToTopListData(_ data: StatsTopPostsTimeIntervalData, filterKind: StatsTopPost.Kind? = nil) -> 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 + let posts = filterKind != nil ? data.topPosts.filter { $0.kind == filterKind } : data.topPosts + + let items = posts.map { post in TopListData.Post( title: post.title, postId: String(post.postID), @@ -272,6 +292,19 @@ actor StatsService: StatsServiceProtocol { } 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) + } } enum StatsServiceError: LocalizedError { From 604cdd7d8301390c71522f6f33425da7a4f8ed53 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Mon, 21 Jul 2025 18:06:02 -0400 Subject: [PATCH 007/349] Add new data item and metric types --- .../JetpackStats/Cards/TopListCard.swift | 27 +++--- .../JetpackStats/Charts/ChartData.swift | 1 + .../Services/Data/SiteMetric.swift | 8 +- .../Services/Data/SiteMetricsSet.swift | 3 + .../Services/Data/TopListChartData.swift | 80 ++++++++++++++++- .../Services/Data/TopListData.swift | 24 +++++ .../Services/Data/TopListItemType.swift | 10 +++ .../Services/Mocks/MockStatsService.swift | 78 ++++++++++++---- .../JetpackStats/Services/StatsService.swift | 89 ++++++++++++++++--- Modules/Sources/JetpackStats/Strings.swift | 5 ++ .../ViewRelated/System/WPTabBarController.m | 2 +- 11 files changed, 286 insertions(+), 41 deletions(-) diff --git a/Modules/Sources/JetpackStats/Cards/TopListCard.swift b/Modules/Sources/JetpackStats/Cards/TopListCard.swift index d75bbab4c172..04bb425f9806 100644 --- a/Modules/Sources/JetpackStats/Cards/TopListCard.swift +++ b/Modules/Sources/JetpackStats/Cards/TopListCard.swift @@ -59,19 +59,26 @@ struct TopListCard: View { Spacer() - Menu { - ForEach(getSupportedMetrics(for: viewModel.selection.item)) { metric in - Button { - viewModel.selection.metric = metric - } label: { - Label(metric.localizedTitle, systemImage: metric.systemImage) + let metrics = getSupportedMetrics(for: viewModel.selection.item) + if metrics.count > 1 { + Menu { + ForEach(metrics) { metric in + Button { + viewModel.selection.metric = metric + } label: { + Label(metric.localizedTitle, systemImage: metric.systemImage) + } } + .tint(Color.primary) + } label: { + InlineValuePickerTitle(title: viewModel.selection.metric.localizedTitle) } - .tint(Color.primary) - } label: { - InlineValuePickerTitle(title: viewModel.selection.metric.localizedTitle) + .fixedSize() + } else { + Text(viewModel.selection.metric.localizedTitle) + .font(.subheadline) + .fontWeight(.medium) } - .fixedSize() } } diff --git a/Modules/Sources/JetpackStats/Charts/ChartData.swift b/Modules/Sources/JetpackStats/Charts/ChartData.swift index 30159893bcd0..3068cc2865c2 100644 --- a/Modules/Sources/JetpackStats/Charts/ChartData.swift +++ b/Modules/Sources/JetpackStats/Charts/ChartData.swift @@ -83,6 +83,7 @@ extension ChartData { case .posts: 10...50 case .timeOnSite: 120...300 case .bounceRate: 40...80 + case .downloads: 100...250 } } } diff --git a/Modules/Sources/JetpackStats/Services/Data/SiteMetric.swift b/Modules/Sources/JetpackStats/Services/Data/SiteMetric.swift index 1d169b0cf674..1cfc260ea79b 100644 --- a/Modules/Sources/JetpackStats/Services/Data/SiteMetric.swift +++ b/Modules/Sources/JetpackStats/Services/Data/SiteMetric.swift @@ -8,6 +8,7 @@ enum SiteMetric: CaseIterable, Identifiable, Sendable { case posts case timeOnSite case bounceRate + case downloads var id: SiteMetric { self } @@ -20,6 +21,7 @@ enum SiteMetric: CaseIterable, Identifiable, Sendable { case .posts: Strings.SiteMetrics.posts case .timeOnSite: Strings.SiteMetrics.timeOnSite case .bounceRate: Strings.SiteMetrics.bounceRate + case .downloads: Strings.SiteMetrics.downloads } } @@ -32,6 +34,7 @@ enum SiteMetric: CaseIterable, Identifiable, Sendable { case .posts: "paragraphsign" case .timeOnSite: "clock" case .bounceRate: "rectangle.portrait.and.arrow.right" + case .downloads: "arrow.down.circle" } } @@ -44,6 +47,7 @@ enum SiteMetric: CaseIterable, Identifiable, Sendable { case .posts: Constants.Colors.celadon case .timeOnSite: Constants.Colors.orange case .bounceRate: Constants.Colors.pink + case .downloads: Constants.Colors.blue } } @@ -55,7 +59,7 @@ enum SiteMetric: CaseIterable, Identifiable, Sendable { extension SiteMetric { var isHigherValueBetter: Bool { switch self { - case .views, .visitors, .likes, .comments, .timeOnSite, .posts: + case .views, .visitors, .likes, .comments, .timeOnSite, .posts, .downloads: return true case .bounceRate: return false @@ -64,7 +68,7 @@ extension SiteMetric { var aggregarionStrategy: AggregationStrategy { switch self { - case .views, .visitors, .likes, .comments, .posts: + case .views, .visitors, .likes, .comments, .posts, .downloads: return .sum case .timeOnSite, .bounceRate: return .average diff --git a/Modules/Sources/JetpackStats/Services/Data/SiteMetricsSet.swift b/Modules/Sources/JetpackStats/Services/Data/SiteMetricsSet.swift index 6ddc56fb7607..189c598a069a 100644 --- a/Modules/Sources/JetpackStats/Services/Data/SiteMetricsSet.swift +++ b/Modules/Sources/JetpackStats/Services/Data/SiteMetricsSet.swift @@ -10,6 +10,7 @@ struct SiteMetricsSet: Codable { var posts: Int? var bounceRate: Int? var timeOnSite: Int? + var downloads: Int? subscript(metric: SiteMetric) -> Int? { get { @@ -21,6 +22,7 @@ struct SiteMetricsSet: Codable { case .posts: posts case .bounceRate: bounceRate case .timeOnSite: timeOnSite + case .downloads: downloads } } set { @@ -32,6 +34,7 @@ struct SiteMetricsSet: Codable { case .posts: posts = newValue case .bounceRate: bounceRate = newValue case .timeOnSite: timeOnSite = newValue + case .downloads: downloads = newValue } } } diff --git a/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift b/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift index 008ad8aaccde..fffc1d3cf3d5 100644 --- a/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift +++ b/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift @@ -64,6 +64,12 @@ extension TopListChartData { return mockAuthors(metric: metric, count: count) case .externalLinks: return mockExternalLinks(metric: metric, count: count) + case .fileDownloads: + return mockFileDownloads(metric: metric, count: count) + case .searchTerms: + return mockSearchTerms(metric: metric, count: count) + case .videos: + return mockVideos(metric: metric, count: count) } } @@ -189,6 +195,75 @@ extension TopListChartData { } } + private static func mockFileDownloads(metric: SiteMetric, count: Int) -> [TopListData.FileDownload] { + let files = [ + ("annual-report-2024.pdf", "/downloads/reports/annual-report-2024.pdf", 2500), + ("swift-cheatsheet.pdf", "/downloads/docs/swift-cheatsheet.pdf", 2100), + ("app-screenshots.zip", "/downloads/media/app-screenshots.zip", 1800), + ("tutorial-video.mp4", "/downloads/videos/tutorial-video.mp4", 1500), + ("code-samples.zip", "/downloads/code/code-samples.zip", 1200), + ("whitepaper.pdf", "/downloads/docs/whitepaper.pdf", 900), + ("presentation.pptx", "/downloads/presentations/presentation.pptx", 600), + ("dataset.csv", "/downloads/data/dataset.csv", 400) + ] + + return files.prefix(count).enumerated().map { index, data in + let baseValue = data.2 + let metrics = createMetrics(baseValue: baseValue, metric: metric) + return TopListData.FileDownload( + fileName: data.0, + filePath: data.1, + metrics: metrics + ) + } + } + + private static func mockSearchTerms(metric: SiteMetric, count: Int) -> [TopListData.SearchTerm] { + let terms = [ + ("swiftui tutorial", 3200), + ("ios development guide", 2800), + ("swift async await", 2400), + ("xcode tips", 2000), + ("swift performance", 1600), + ("ios app architecture", 1200), + ("swiftui animation", 800), + ("swift best practices", 500) + ] + + return terms.prefix(count).enumerated().map { index, data in + let baseValue = data.1 + let metrics = createMetrics(baseValue: baseValue, metric: metric) + return TopListData.SearchTerm( + term: data.0, + metrics: metrics + ) + } + } + + private static func mockVideos(metric: SiteMetric, count: Int) -> [TopListData.Video] { + let videos = [ + ("Getting Started with SwiftUI", "101", "https://example.com/videos/swiftui-intro.mp4", 4500), + ("iOS Development Best Practices", "102", "https://example.com/videos/best-practices.mp4", 3800), + ("Advanced Swift Techniques", "103", "https://example.com/videos/advanced-swift.mp4", 3200), + ("Building Custom Views", "104", "https://example.com/videos/custom-views.mp4", 2600), + ("App Performance Optimization", "105", "https://example.com/videos/performance.mp4", 2000), + ("Debugging Like a Pro", "106", "https://example.com/videos/debugging.mp4", 1500), + ("SwiftUI Animations", "107", "https://example.com/videos/animations.mp4", 1000), + ("Testing Strategies", "108", "https://example.com/videos/testing.mp4", 700) + ] + + return videos.prefix(count).enumerated().map { index, data in + let baseValue = data.3 + let metrics = createMetrics(baseValue: baseValue, metric: metric) + return TopListData.Video( + title: data.0, + postId: data.1, + videoUrl: URL(string: data.2), + metrics: metrics + ) + } + } + 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) @@ -211,7 +286,10 @@ extension TopListChartData { return SiteMetricsSet(comments: Int(Double(value) * commentRatio)) case .posts: let postsRatio = Double.random(in: 0.002...0.005) - return SiteMetricsSet(comments: Int(Double(value) * postsRatio)) + return SiteMetricsSet(posts: Int(Double(value) * postsRatio)) + case .downloads: + // Generic count metric (used for downloads, etc.) + return SiteMetricsSet(downloads: value) case .timeOnSite: // Time on site not applicable for top list items return SiteMetricsSet(views: value) diff --git a/Modules/Sources/JetpackStats/Services/Data/TopListData.swift b/Modules/Sources/JetpackStats/Services/Data/TopListData.swift index 8974e595e11f..0e09c5cc1ab3 100644 --- a/Modules/Sources/JetpackStats/Services/Data/TopListData.swift +++ b/Modules/Sources/JetpackStats/Services/Data/TopListData.swift @@ -56,4 +56,28 @@ extension TopListData { var id: String { url } } + + struct FileDownload: Codable, TopListItem { + let fileName: String + let filePath: String? + var metrics: SiteMetricsSet + + var id: String { filePath ?? fileName } + } + + struct SearchTerm: Codable, TopListItem { + let term: String + var metrics: SiteMetricsSet + + var id: String { term } + } + + struct Video: Codable, TopListItem { + let title: String + let postId: String + let videoUrl: URL? + var metrics: SiteMetricsSet + + var id: String { postId } + } } diff --git a/Modules/Sources/JetpackStats/Services/Data/TopListItemType.swift b/Modules/Sources/JetpackStats/Services/Data/TopListItemType.swift index ee48ee4c412f..b773145e1626 100644 --- a/Modules/Sources/JetpackStats/Services/Data/TopListItemType.swift +++ b/Modules/Sources/JetpackStats/Services/Data/TopListItemType.swift @@ -8,6 +8,9 @@ enum TopListItemType: Identifiable, CaseIterable, Sendable { case referrers case locations case externalLinks + case fileDownloads + case searchTerms + case videos var id: TopListItemType { self } @@ -20,6 +23,9 @@ enum TopListItemType: Identifiable, CaseIterable, Sendable { case .referrers: Strings.SiteDataTypes.referrers case .locations: Strings.SiteDataTypes.locations case .externalLinks: Strings.SiteDataTypes.externalLinks + case .fileDownloads: Strings.SiteDataTypes.fileDownloads + case .searchTerms: Strings.SiteDataTypes.searchTerms + case .videos: Strings.SiteDataTypes.videos } } @@ -32,6 +38,9 @@ enum TopListItemType: Identifiable, CaseIterable, Sendable { case .locations: "map" case .authors: "person.2" case .externalLinks: "cursorarrow.click" + case .fileDownloads: "arrow.down.circle" + case .searchTerms: "magnifyingglass" + case .videos: "play.rectangle" } } @@ -44,6 +53,7 @@ enum TopListItemType: Identifiable, CaseIterable, Sendable { case .posts: Strings.TopListTitles.mostPosts case .bounceRate: Strings.TopListTitles.highestBounceRate case .timeOnSite: Strings.TopListTitles.longestTimeOnSite + case .downloads: Strings.TopListTitles.mostDownloadeded } } } diff --git a/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift b/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift index 293c83e4f020..ac0a5bdf55ba 100644 --- a/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift +++ b/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift @@ -16,6 +16,9 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { case .locations: [.views, .visitors] case .authors: [.views, .comments, .likes] case .externalLinks: [.views, .visitors] + case .fileDownloads: [.downloads] + case .searchTerms: [.views, .visitors] + case .videos: [.views, .likes] } } @@ -73,24 +76,25 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { for (_, dailyItems) in filteredData { for item in dailyItems { let key = item.id - if let (existingItem, existingViews) = aggregatedItems[key] { - // Aggregate views - aggregatedItems[key] = (existingItem, existingViews + (item.metrics.views ?? 0)) + if let (existingItem, existingValue) = aggregatedItems[key] { + // Aggregate based on metric + let metricValue = item.metrics[metric] ?? 0 + aggregatedItems[key] = (existingItem, existingValue + metricValue) } else { - aggregatedItems[key] = (item, item.metrics.views ?? 0) + aggregatedItems[key] = (item, item.metrics[metric] ?? 0) } } } - // Convert to array with updated views and sort + // Convert to array with updated metric value and sort let sortedItems = aggregatedItems.values - .map { (item, totalViews) -> any TopListItem in - // Create a mutable copy and update the aggregated views + .map { (item, totalValue) -> any TopListItem in + // Create a mutable copy and update the aggregated metric value var mutableItem = item - mutableItem.metrics.views = totalViews + mutableItem.metrics[metric] = totalValue return mutableItem } - .sorted { ($0.metrics.views ?? 0) > ($1.metrics.views ?? 0) } + .sorted { ($0.metrics[metric] ?? 0) > ($1.metrics[metric] ?? 0) } try? await Task.sleep(for: .milliseconds(Int.random(in: 200...500))) @@ -147,6 +151,9 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { let timeVariation = Double.random(in: 0.85...1.15) mutableItem.metrics.timeOnSite = Int(Double(timeOnSite) * timeVariation) } + if let downloads = mutableItem.metrics.downloads { + mutableItem.metrics.downloads = Int(Double(downloads) * slowWave * smallVariation * rareSpike) + } return mutableItem } @@ -181,8 +188,13 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { case .authors: fileName = "authors" case .externalLinks: - // Return empty array for now as we're not implementing mocks yet - return [] + fileName = "external-links" + case .fileDownloads: + fileName = "file-downloads" + case .searchTerms: + fileName = "search-terms" + case .videos: + fileName = "videos" } // Load from JSON file @@ -215,7 +227,19 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { } return copy } - case .postsAndPages, .externalLinks: + case .externalLinks: + let links = try decoder.decode([TopListData.ExternalLink].self, from: data) + return links + case .fileDownloads: + let downloads = try decoder.decode([TopListData.FileDownload].self, from: data) + return downloads + case .searchTerms: + let terms = try decoder.decode([TopListData.SearchTerm].self, from: data) + return terms + case .videos: + let videos = try decoder.decode([TopListData.Video].self, from: data) + return videos + case .postsAndPages: return [] // Already handled above } } catch { @@ -266,8 +290,13 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { case .authors: fileName = "historical-authors" case .externalLinks: - // Return empty array for now as we're not implementing mocks yet - return [] + fileName = "historical-external-links" + case .fileDownloads: + fileName = "historical-file-downloads" + case .searchTerms: + fileName = "historical-search-terms" + case .videos: + fileName = "historical-videos" } // Load from JSON file @@ -300,7 +329,19 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { } return copy } - case .postsAndPages, .externalLinks: + case .externalLinks: + let links = try decoder.decode([TopListData.ExternalLink].self, from: data) + return links + case .fileDownloads: + let downloads = try decoder.decode([TopListData.FileDownload].self, from: data) + return downloads + case .searchTerms: + let terms = try decoder.decode([TopListData.SearchTerm].self, from: data) + return terms + case .videos: + let videos = try decoder.decode([TopListData.Video].self, from: data) + return videos + case .postsAndPages: return [] // Already handled above } } catch { @@ -336,6 +377,9 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { let timeVariation = Double.random(in: 0.85...1.15) item.metrics.timeOnSite = Int(Double(timeOnSite) * timeVariation) } + if let downloads = item.metrics.downloads { + item.metrics.downloads = Int(Double(downloads) * combinedFactor) + } return item } @@ -417,6 +461,10 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { // Percentage - inverse relationship with engagement let engagementFactor = growthFactor * seasonalFactor return Int(75 - (5 * engagementFactor) + Double.random(in: -5...5)) + + case .downloads: + let baseValue = 50.0 + return Int(baseValue * growthFactor * seasonalFactor * weekendFactor * randomFactor) } } diff --git a/Modules/Sources/JetpackStats/Services/StatsService.swift b/Modules/Sources/JetpackStats/Services/StatsService.swift index 62fd5a02c810..aa375974479b 100644 --- a/Modules/Sources/JetpackStats/Services/StatsService.swift +++ b/Modules/Sources/JetpackStats/Services/StatsService.swift @@ -20,7 +20,8 @@ actor StatsService: StatsServiceProtocol { ] let supportedItems: [TopListItemType] = [ - .postsAndPages, .posts, .pages, .referrers, .locations, .authors, .externalLinks + .postsAndPages, .posts, .pages, .referrers, .locations, .authors, .externalLinks, + .fileDownloads, .searchTerms, .videos ] nonisolated func getSupportedMetrics(for item: TopListItemType) -> [SiteMetric] { @@ -30,6 +31,9 @@ actor StatsService: StatsServiceProtocol { case .locations: [.views] case .authors: [.views] case .externalLinks: [.views] + case .fileDownloads: [.downloads] + case .searchTerms: [.views] + case .videos: [.views] } } @@ -143,6 +147,33 @@ actor StatsService: StatsServiceProtocol { default: throw StatsServiceError.unavailable } + + case .fileDownloads: + switch metric { + case .downloads: + let data = try await getData(StatsFileDownloadsTimeIntervalData.self) + return mapFileDownloadsToTopListData(data) + default: + throw StatsServiceError.unavailable + } + + case .searchTerms: + switch metric { + case .views: + let data = try await getData(StatsSearchTermTimeIntervalData.self) + return mapSearchTermsToTopListData(data) + default: + throw StatsServiceError.unavailable + } + + case .videos: + switch metric { + case .views: + let data = try await getData(StatsTopVideosTimeIntervalData.self) + return mapVideosToTopListData(data) + default: + throw StatsServiceError.unavailable + } } } @@ -204,12 +235,13 @@ actor StatsService: StatsServiceProtocol { var total = SiteMetricsSet() var metrics: [SiteMetric: [DataPoint]] = [:] for metric in supportedMetrics { - let mappedMetric = WordPressKit.StatsSiteMetricsResponse.Metric(metric) - let dataPoints = response.data.compactMap { - makeDataPoint(from: $0, metric: mappedMetric) + if let mappedMetric = WordPressKit.StatsSiteMetricsResponse.Metric(metric) { + let dataPoints = response.data.compactMap { + makeDataPoint(from: $0, metric: mappedMetric) + } + metrics[metric] = dataPoints + total[metric] = DataPoint.getTotalValue(for: dataPoints, metric: metric) } - metrics[metric] = dataPoints - total[metric] = DataPoint.getTotalValue(for: dataPoints, metric: metric) } return SiteMetricsData(total: total, metrics: metrics) } @@ -221,7 +253,6 @@ actor StatsService: StatsServiceProtocol { 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 TopListData.Post( title: post.title, @@ -305,6 +336,43 @@ actor StatsService: StatsServiceProtocol { } 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) + } } enum StatsServiceError: LocalizedError { @@ -340,18 +408,15 @@ private extension StatsTopPost.Kind { } } -// TODO: rework this private extension WordPressKit.StatsSiteMetricsResponse.Metric { - init(_ metric: SiteMetric) { + init?(_ metric: SiteMetric) { switch metric { case .views: self = .views case .visitors: self = .visitors case .likes: self = .likes case .comments: self = .comments case .posts: self = .posts - case .timeOnSite, .bounceRate: - wpAssertionFailure("not supported") - self = .views + case .timeOnSite, .bounceRate, .downloads: return nil } } } diff --git a/Modules/Sources/JetpackStats/Strings.swift b/Modules/Sources/JetpackStats/Strings.swift index 4d8fd28370fc..6e13780c27de 100644 --- a/Modules/Sources/JetpackStats/Strings.swift +++ b/Modules/Sources/JetpackStats/Strings.swift @@ -41,6 +41,7 @@ enum Strings { static let posts = AppLocalizedString("jetpackStats.siteMetrics.posts", value: "Posts", comment: "Site posts metric") static let timeOnSite = AppLocalizedString("jetpackStats.siteMetrics.timeOnSite", value: "Time on Site", comment: "Time on site metric") static let bounceRate = AppLocalizedString("jetpackStats.siteMetrics.bounceRate", value: "Bounce Rate", comment: "Bounce rate metric") + static let downloads = AppLocalizedString("jetpackStats.siteMetrics.downloads", value: "Downloads", comment: "Download count") } enum SiteDataTypes { @@ -51,6 +52,9 @@ enum Strings { static let referrers = AppLocalizedString("jetpackStats.siteDataTypes.referrers", value: "Referrers", comment: "Referrers data type") static let locations = AppLocalizedString("jetpackStats.siteDataTypes.locations", value: "Locations", comment: "Locations data type") static let externalLinks = AppLocalizedString("jetpackStats.siteDataTypes.externalLinks", value: "External Links", comment: "External links data type") + 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") } enum Buttons { @@ -91,6 +95,7 @@ enum Strings { static let mostPosts = AppLocalizedString("jetpackStats.topList.mostPosts", value: "Most Posts", comment: "Title for most posts (per author)") static let highestBounceRate = AppLocalizedString("jetpackStats.topList.highestBounceRate", value: "Highest Bounce Rate", comment: "Title for items with highest bounce rate") static let longestTimeOnSite = AppLocalizedString("jetpackStats.topList.longestTimeOnSite", value: "Longest Time on Site", comment: "Title for items with longest time on site") + static let mostDownloadeded = AppLocalizedString("jetpackStats.topList.mostDownloads", value: "Most Downloaded", comment: "Title for chart") } enum Errors { diff --git a/WordPress/Classes/ViewRelated/System/WPTabBarController.m b/WordPress/Classes/ViewRelated/System/WPTabBarController.m index 1b62b2a1c6b8..9f4b33ace7be 100644 --- a/WordPress/Classes/ViewRelated/System/WPTabBarController.m +++ b/WordPress/Classes/ViewRelated/System/WPTabBarController.m @@ -311,7 +311,7 @@ - (BOOL)tabBarController:(UITabBarController *)tabBarController shouldSelectView - (void)tabBar:(UITabBar *)tabBar didSelectItem:(UITabBarItem *)item { - UIImpactFeedbackGenerator *generator = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleMedium]; + UIImpactFeedbackGenerator *generator = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleSoft]; [generator impactOccurred]; [self animateSelectedItem:item for:tabBar]; From 6e8b2207b978b1f9846999e788612a4a83073b5d Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Mon, 21 Jul 2025 18:31:22 -0400 Subject: [PATCH 008/349] Fix convertDateSiteToLocal --- Modules/Sources/JetpackStats/Services/StatsService.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Modules/Sources/JetpackStats/Services/StatsService.swift b/Modules/Sources/JetpackStats/Services/StatsService.swift index aa375974479b..9182e42107d1 100644 --- a/Modules/Sources/JetpackStats/Services/StatsService.swift +++ b/Modules/Sources/JetpackStats/Services/StatsService.swift @@ -198,7 +198,9 @@ actor StatsService: StatsServiceProtocol { /// timezone (expected by WordPressKit) while preserving the date components. private func convertDateSiteToLocal(_ date: Date) -> Date { let calendar = Calendar.current - let components = calendar.dateComponents(in: siteTimeZone, from: date) + var components = calendar.dateComponents(in: siteTimeZone, from: date) + components.timeZone = nil + components.nanosecond = nil guard let output = calendar.date(from: components) else { wpAssertionFailure("failed to convert date to local time zone", userInfo: ["date": date]) return date From 1dffe1483bced8d5e484204048df9bde7d148f23 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Mon, 21 Jul 2025 18:36:57 -0400 Subject: [PATCH 009/349] Add new rows to support new ItemTypes --- Modules/Sources/JetpackStats/Strings.swift | 13 +++++++++ .../Rows/TopListFileDownloadRowView.swift | 22 +++++++++++++++ .../Rows/TopListSearchTermRowView.swift | 22 +++++++++++++++ .../TopList/Rows/TopListVideoRowView.swift | 28 +++++++++++++++++++ .../Views/TopList/TopListItemView.swift | 6 ++++ .../Views/TopList/TopListMetricsView.swift | 2 +- 6 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 Modules/Sources/JetpackStats/Views/TopList/Rows/TopListFileDownloadRowView.swift create mode 100644 Modules/Sources/JetpackStats/Views/TopList/Rows/TopListSearchTermRowView.swift create mode 100644 Modules/Sources/JetpackStats/Views/TopList/Rows/TopListVideoRowView.swift diff --git a/Modules/Sources/JetpackStats/Strings.swift b/Modules/Sources/JetpackStats/Strings.swift index 6e13780c27de..004f2bc8289a 100644 --- a/Modules/Sources/JetpackStats/Strings.swift +++ b/Modules/Sources/JetpackStats/Strings.swift @@ -101,4 +101,17 @@ enum Strings { enum Errors { static let generic = AppLocalizedString("jetpackStats.chart.generitcError", value: "Something went wrong", comment: "Genertic error message") } + + enum SearchTerms { + static let fromSearch = AppLocalizedString("jetpackStats.searchTerms.fromSearch", value: "From search", comment: "Caption shown below search terms") + } + + enum Videos { + static func postId(_ id: String) -> String { + String.localizedStringWithFormat( + AppLocalizedString("jetpackStats.videos.postId", value: "Post #%1$@", comment: "Post ID for video. %1$@ is the post ID"), + id + ) + } + } } diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListFileDownloadRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListFileDownloadRowView.swift new file mode 100644 index 000000000000..115afa935a4c --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListFileDownloadRowView.swift @@ -0,0 +1,22 @@ +import SwiftUI + +struct TopListFileDownloadRowView: View { + let item: TopListData.FileDownload + let showDetails: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(item.fileName) + .font(.callout) + .foregroundColor(.primary) + .lineLimit(1) + + if showDetails, let filePath = item.filePath { + Text(verbatim: filePath) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + } +} \ No newline at end of file diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListSearchTermRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListSearchTermRowView.swift new file mode 100644 index 000000000000..60e56612278d --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListSearchTermRowView.swift @@ -0,0 +1,22 @@ +import SwiftUI + +struct TopListSearchTermRowView: View { + let item: TopListData.SearchTerm + let showDetails: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(item.term) + .font(.callout) + .foregroundColor(.primary) + .lineLimit(1) + + if showDetails { + Text(Strings.SearchTerms.fromSearch) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + } +} \ No newline at end of file diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListVideoRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListVideoRowView.swift new file mode 100644 index 000000000000..347273ab03de --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListVideoRowView.swift @@ -0,0 +1,28 @@ +import SwiftUI + +struct TopListVideoRowView: View { + let item: TopListData.Video + let showDetails: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Image(systemName: "play.circle.fill") + .font(.caption) + .foregroundColor(.secondary) + + Text(item.title) + .font(.callout) + .foregroundColor(.primary) + .lineLimit(1) + } + + if showDetails { + Text(Strings.Videos.postId(item.postId)) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + } +} \ No newline at end of file diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift index f624823bb1f5..80a68a6f8eaa 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift @@ -21,6 +21,12 @@ struct TopListItemView: View { TopListAuthorRowView(item: author, showDetails: showDetails) case let link as TopListData.ExternalLink: TopListExternalLinkRowView(item: link, showDetails: showDetails) + case let download as TopListData.FileDownload: + TopListFileDownloadRowView(item: download, showDetails: showDetails) + case let searchTerm as TopListData.SearchTerm: + TopListSearchTermRowView(item: searchTerm, showDetails: showDetails) + case let video as TopListData.Video: + TopListVideoRowView(item: video, showDetails: showDetails) default: let _ = assertionFailure("unsupported item: \(currentItem)") EmptyView() diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListMetricsView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListMetricsView.swift index b830c01a32e3..b055c3b3f992 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListMetricsView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListMetricsView.swift @@ -28,7 +28,7 @@ struct TopListMetricsView: View { .padding(.trailing, -2) } } - .animation(.smooth, value: ID(currentValue: currentValue, previousValue: previousValue)) + } private var trend: TrendViewModel? { From 87eb4845fcca3696562d008905c9828ef2dc6141 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Mon, 21 Jul 2025 18:47:59 -0400 Subject: [PATCH 010/349] Move secondary items --- .../JetpackStats/Cards/TopListCard.swift | 28 +++++---- .../Cards/TopListCardViewModel.swift | 12 ++++ .../Services/Data/TopListItemType.swift | 4 ++ .../JetpackStats/Services/StatsService.swift | 59 +++++++++++++++++++ 4 files changed, 91 insertions(+), 12 deletions(-) diff --git a/Modules/Sources/JetpackStats/Cards/TopListCard.swift b/Modules/Sources/JetpackStats/Cards/TopListCard.swift index 04bb425f9806..e7a98b20580a 100644 --- a/Modules/Sources/JetpackStats/Cards/TopListCard.swift +++ b/Modules/Sources/JetpackStats/Cards/TopListCard.swift @@ -36,19 +36,23 @@ struct TopListCard: View { private var headerView: some View { HStack { Menu { - ForEach(viewModel.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 + 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) diff --git a/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift b/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift index 561782ecfd76..11e1e28062b7 100644 --- a/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift +++ b/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift @@ -3,6 +3,7 @@ import SwiftUI @MainActor final class TopListCardViewModel: ObservableObject, TrafficCardViewModel { let items: [TopListItemType] + let groupedItems: [[TopListItemType]] var title: String { selection.item.getTitle(for: selection.metric) @@ -42,6 +43,17 @@ final class TopListCardViewModel: ObservableObject, TrafficCardViewModel { self.selection = selection self.dateRange = dateRange self.service = service + + + self.groupedItems = { + var primary = service.supportedItems.filter { + !TopListItemType.secondaryItems.contains($0) + } + var secondary = service.supportedItems.filter { + TopListItemType.secondaryItems.contains($0) + } + return [primary, secondary] + }() } func onAppear() { diff --git a/Modules/Sources/JetpackStats/Services/Data/TopListItemType.swift b/Modules/Sources/JetpackStats/Services/Data/TopListItemType.swift index b773145e1626..2278d9bcc37e 100644 --- a/Modules/Sources/JetpackStats/Services/Data/TopListItemType.swift +++ b/Modules/Sources/JetpackStats/Services/Data/TopListItemType.swift @@ -56,4 +56,8 @@ enum TopListItemType: Identifiable, CaseIterable, Sendable { case .downloads: Strings.TopListTitles.mostDownloadeded } } + + static let secondaryItems: Set = [ + .externalLinks, .fileDownloads, .searchTerms, .videos + ] } diff --git a/Modules/Sources/JetpackStats/Services/StatsService.swift b/Modules/Sources/JetpackStats/Services/StatsService.swift index 9182e42107d1..5ae95046ac54 100644 --- a/Modules/Sources/JetpackStats/Services/StatsService.swift +++ b/Modules/Sources/JetpackStats/Services/StatsService.swift @@ -15,6 +15,10 @@ actor StatsService: StatsServiceProtocol { // Temporary private var mocks: MockStatsService + // Cache + private var siteStatsCache: [SiteStatsCacheKey: CachedSiteStats] = [:] + private let currentPeriodTTL: TimeInterval = 30 // 30 seconds for current period + let supportedMetrics: [SiteMetric] = [ .views, .visitors, .likes, .comments, .posts ] @@ -52,6 +56,30 @@ actor StatsService: StatsServiceProtocol { // MARK: - StatsServiceProtocol func getSiteStats(interval: DateInterval, granularity: DateRangeGranularity) async throws -> SiteMetricsData { + // Check cache first + let cacheKey = SiteStatsCacheKey(interval: interval, granularity: granularity) + + if let cached = siteStatsCache[cacheKey], !cached.isExpired { + return cached.data + } + + // Fetch fresh data + let data = try await fetchSiteStats(interval: interval, granularity: granularity) + + // Cache the result + // Historical data never expires (ttl = nil), current period data expires after 30 seconds + let ttl = intervalContainsCurrentDate(interval) ? currentPeriodTTL : nil + + siteStatsCache[cacheKey] = CachedSiteStats( + data: data, + timestamp: Date(), + ttl: ttl + ) + + return data + } + + private func fetchSiteStats(interval: DateInterval, granularity: DateRangeGranularity) async throws -> SiteMetricsData { let interval = convertDateIntervalSiteToLocal(interval) if granularity == .hour { @@ -194,6 +222,17 @@ actor StatsService: StatsServiceProtocol { return DateInterval(start: start, end: end) } + /// Checks if the date interval contains the current date in the site's timezone + private func intervalContainsCurrentDate(_ interval: DateInterval) -> Bool { + var calendar = Calendar.current + calendar.timeZone = siteTimeZone + let now = Date() + let startOfToday = calendar.startOfDay(for: now) + let endOfToday = calendar.date(byAdding: .day, value: 1, to: startOfToday)!.addingTimeInterval(-1) + + return interval.start <= endOfToday && interval.end >= startOfToday + } + /// Convert from the site timezone (used in JetpackState) to the local /// timezone (expected by WordPressKit) while preserving the date components. private func convertDateSiteToLocal(_ date: Date) -> Date { @@ -386,6 +425,26 @@ enum StatsServiceError: LocalizedError { } } +// MARK: - Cache + +private struct SiteStatsCacheKey: Hashable { + let interval: DateInterval + let granularity: DateRangeGranularity +} + +private struct CachedSiteStats { + let data: SiteMetricsData + let timestamp: Date + let ttl: TimeInterval? + + var isExpired: Bool { + guard let ttl else { + return false // No TTL means it never expires + } + return Date().timeIntervalSince(timestamp) > ttl + } +} + // MARK: - Mapping private extension WordPressKit.StatsPeriodUnit { From b1462f227fb3199b2b4a53cd7fd94e1ff56af4ad Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Tue, 22 Jul 2025 08:48:57 -0400 Subject: [PATCH 011/349] Add caching --- Modules/Sources/JetpackStats/Services/StatsService.swift | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/Modules/Sources/JetpackStats/Services/StatsService.swift b/Modules/Sources/JetpackStats/Services/StatsService.swift index 5ae95046ac54..d8eb7147f57d 100644 --- a/Modules/Sources/JetpackStats/Services/StatsService.swift +++ b/Modules/Sources/JetpackStats/Services/StatsService.swift @@ -70,11 +70,7 @@ actor StatsService: StatsServiceProtocol { // Historical data never expires (ttl = nil), current period data expires after 30 seconds let ttl = intervalContainsCurrentDate(interval) ? currentPeriodTTL : nil - siteStatsCache[cacheKey] = CachedSiteStats( - data: data, - timestamp: Date(), - ttl: ttl - ) + siteStatsCache[cacheKey] = CachedSiteStats(data: data, timestamp: Date(), ttl: ttl) return data } From 705ec106e2d435ea815b6c6e08410cc6e3fa11d1 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Tue, 22 Jul 2025 08:58:24 -0400 Subject: [PATCH 012/349] Improve percentage formatting and use Decimal for more precision --- .../JetpackStats/Cards/TopListCardViewModel.swift | 1 - .../JetpackStats/Utilities/TrendViewModel.swift | 10 +++++++--- .../TopList/Rows/TopListFileDownloadRowView.swift | 2 +- .../TopList/Rows/TopListSearchTermRowView.swift | 2 +- .../Views/TopList/Rows/TopListVideoRowView.swift | 4 ++-- .../JetpackStatsTests/MockStatsServiceTests.swift | 12 ++++++++++-- .../JetpackStatsTests/TrendViewModelTests.swift | 3 ++- 7 files changed, 23 insertions(+), 11 deletions(-) diff --git a/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift b/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift index 11e1e28062b7..a2eb7ffe0c79 100644 --- a/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift +++ b/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift @@ -44,7 +44,6 @@ final class TopListCardViewModel: ObservableObject, TrafficCardViewModel { self.dateRange = dateRange self.service = service - self.groupedItems = { var primary = service.supportedItems.filter { !TopListItemType.secondaryItems.contains($0) diff --git a/Modules/Sources/JetpackStats/Utilities/TrendViewModel.swift b/Modules/Sources/JetpackStats/Utilities/TrendViewModel.swift index 63d128f5b4c3..83a1c19cf104 100644 --- a/Modules/Sources/JetpackStats/Utilities/TrendViewModel.swift +++ b/Modules/Sources/JetpackStats/Utilities/TrendViewModel.swift @@ -37,11 +37,11 @@ struct TrendViewModel: Hashable { /// The percentage change between periods (nil if the previous value was 0). /// - Example: 0.5 for 50% increase, 0.25 for 25% decrease - var percentage: Double? { + var percentage: Decimal? { guard previousValue != 0 else { return nil } - return Double(abs(currentValue - previousValue)) / Double(abs(previousValue)) + return Decimal(abs(currentValue - previousValue)) / Decimal(abs(previousValue)) } // MARK: Formatting @@ -93,7 +93,11 @@ struct TrendViewModel: Hashable { guard let percentage else { return "∞" } - return percentage.formatted(.percent.precision(.fractionLength(0...1))) + return percentage.formatted( + .percent + .notation(.compactName) + .precision(.fractionLength(0...1)) + ) } } diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListFileDownloadRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListFileDownloadRowView.swift index 115afa935a4c..1183e149e5d9 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListFileDownloadRowView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListFileDownloadRowView.swift @@ -19,4 +19,4 @@ struct TopListFileDownloadRowView: View { } } } -} \ No newline at end of file +} diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListSearchTermRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListSearchTermRowView.swift index 60e56612278d..4513080ab3b9 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListSearchTermRowView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListSearchTermRowView.swift @@ -19,4 +19,4 @@ struct TopListSearchTermRowView: View { } } } -} \ No newline at end of file +} diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListVideoRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListVideoRowView.swift index 347273ab03de..8179dd7a7af6 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListVideoRowView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListVideoRowView.swift @@ -10,7 +10,7 @@ struct TopListVideoRowView: View { Image(systemName: "play.circle.fill") .font(.caption) .foregroundColor(.secondary) - + Text(item.title) .font(.callout) .foregroundColor(.primary) @@ -25,4 +25,4 @@ struct TopListVideoRowView: View { } } } -} \ No newline at end of file +} diff --git a/Modules/Tests/JetpackStatsTests/MockStatsServiceTests.swift b/Modules/Tests/JetpackStatsTests/MockStatsServiceTests.swift index 302bed26ee5c..de813298a92f 100644 --- a/Modules/Tests/JetpackStatsTests/MockStatsServiceTests.swift +++ b/Modules/Tests/JetpackStatsTests/MockStatsServiceTests.swift @@ -13,7 +13,12 @@ struct MockStatsServiceTests { let dateInterval = calendar.makeDateInterval(for: .today) // WHEN - let response = try await service.getTopListData(.posts, interval: dateInterval, granularity: dateInterval.preferredGranularity) + let response = try await service.getTopListData( + .posts, + metric: .views, + interval: dateInterval, + granularity: dateInterval.preferredGranularity + ) // THEN #expect(response.items.count > 0) @@ -39,7 +44,10 @@ struct MockStatsServiceTests { let granularity = dateInterval.preferredGranularity // WHEN - let response = try await service.getSiteStats(interval: dateInterval, granularity: granularity) + let response = try await service.getSiteStats( + interval: dateInterval, + granularity: granularity + ) // THEN - Basic validations #expect(response.metrics.count > 0, "Should return at least one data point") diff --git a/Modules/Tests/JetpackStatsTests/TrendViewModelTests.swift b/Modules/Tests/JetpackStatsTests/TrendViewModelTests.swift index 03b7b3a7072c..2c717f107148 100644 --- a/Modules/Tests/JetpackStatsTests/TrendViewModelTests.swift +++ b/Modules/Tests/JetpackStatsTests/TrendViewModelTests.swift @@ -62,7 +62,7 @@ struct TrendViewModelTests { (0, 100, 1.0), // 100% decrease (100, 100, 0.0) // No change ]) - func testPercentageCalculation(current: Int, previous: Int, expected: Double) { + func testPercentageCalculation(current: Int, previous: Int, expected: Decimal) { // GIVEN let viewModel = TrendViewModel(currentValue: current, previousValue: previous, metric: .views) @@ -122,6 +122,7 @@ struct TrendViewModelTests { @Test("Formatted percentage string", arguments: [ (150, 100, "50%"), (175, 100, "75%"), + (1, 1000, "1K%"), (100, 100, "0%"), (125, 100, "25%"), (100, 0, "∞") From f0a8ae0c50062b1159cbd9a1c4bfaa7b920353f1 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Tue, 22 Jul 2025 09:11:11 -0400 Subject: [PATCH 013/349] Use rounded monospaced digits in ChartValuesSummaryView --- .../Views/ChartValuesSummaryView.swift | 62 ++++++++++--------- 1 file changed, 34 insertions(+), 28 deletions(-) diff --git a/Modules/Sources/JetpackStats/Views/ChartValuesSummaryView.swift b/Modules/Sources/JetpackStats/Views/ChartValuesSummaryView.swift index fa29dfc3a072..32413ad885ac 100644 --- a/Modules/Sources/JetpackStats/Views/ChartValuesSummaryView.swift +++ b/Modules/Sources/JetpackStats/Views/ChartValuesSummaryView.swift @@ -12,38 +12,44 @@ struct ChartValuesSummaryView: View { var body: some View { Group { switch style { - case .standard: - HStack(alignment: .center, spacing: 16) { - Text(trend.formattedCurrentValue) - .font(Font.make(.recoleta, textStyle: .title, weight: .medium)) - .foregroundColor(.primary) - .contentTransition(.numericText()) - - BadgeTrendIndicator(trend: trend) - } - case .compact: - HStack(alignment: .center, spacing: 12) { - Text(trend.formattedCurrentValue) - .font(.subheadline.weight(.medium)) - .foregroundColor(.primary) - .contentTransition(.numericText()) - - Group { - Text(trend.formattedChange) - HStack(spacing: 2) { - Image(systemName: trend.systemImage) - .font(.caption2.weight(.medium)) - Text(trend.formattedPercentage) - } - } - .contentTransition(.numericText()) - .font(.subheadline.weight(.medium)) - .foregroundColor(trend.sentiment.foregroundColor) - } + case .standard: standard + case .compact: compact } } .animation(.default, value: trend) } + + private var standard: some View { + HStack(alignment: .center, spacing: 16) { + Text(trend.formattedCurrentValue) + .font(Font.make(.recoleta, textStyle: .title, weight: .medium)) + .foregroundColor(.primary) + .contentTransition(.numericText()) + + BadgeTrendIndicator(trend: trend) + } + } + + private var compact: some View { + HStack(alignment: .center, spacing: 12) { + Text(trend.formattedCurrentValue) + .font(.system(.subheadline, design: .rounded, weight: .medium)) + .foregroundColor(.primary) + .contentTransition(.numericText()) + + Group { + Text(trend.formattedChange) + HStack(spacing: 2) { + Image(systemName: trend.systemImage) + .font(.caption2.weight(.medium)) + Text(trend.formattedPercentage) + } + } + .contentTransition(.numericText()) + .font(.system(.subheadline, design: .rounded, weight: .medium)) + .foregroundColor(trend.sentiment.foregroundColor) + } + } } #Preview { From 9da8ca39dd0029923c6d829227cdc03dd1fb22c1 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Tue, 22 Jul 2025 09:20:57 -0400 Subject: [PATCH 014/349] Darker rows in dark mode --- .../JetpackStats/Views/TopList/TopListItemBarBackground.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListItemBarBackground.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListItemBarBackground.swift index e3b1dedaa364..39d756779f7e 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListItemBarBackground.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemBarBackground.swift @@ -11,7 +11,7 @@ struct TopListItemBarBackground: View { GeometryReader { geometry in HStack(spacing: 0) { RoundedRectangle(cornerRadius: 6) - .fill(barColor.opacity(colorScheme == .light ? 0.09 : 0.5)) + .fill(barColor.opacity(colorScheme == .light ? 0.09 : 0.25)) .frame(width: barWidth(in: geometry)) .animation(.spring(response: 0.4, dampingFraction: 0.8), value: value) Spacer(minLength: 0) From 96e587068008f82ce7608dc60905601a01f565e2 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Tue, 22 Jul 2025 10:33:51 -0400 Subject: [PATCH 015/349] Fix transition in TopListItemsView --- .../Cards/RealtimeTopListCard.swift | 5 +++- .../Cards/TopListCardViewModel.swift | 4 +-- .../Services/Data/TopListChartData.swift | 30 +++++++++++++++---- .../TopList/TopListItemBarBackground.swift | 1 - .../Views/TopList/TopListItemsView.swift | 8 +++-- .../Views/TopList/TopListMetricsView.swift | 7 +---- 6 files changed, 36 insertions(+), 19 deletions(-) diff --git a/Modules/Sources/JetpackStats/Cards/RealtimeTopListCard.swift b/Modules/Sources/JetpackStats/Cards/RealtimeTopListCard.swift index 66a047c2ad99..65f2484fe1ae 100644 --- a/Modules/Sources/JetpackStats/Cards/RealtimeTopListCard.swift +++ b/Modules/Sources/JetpackStats/Cards/RealtimeTopListCard.swift @@ -95,7 +95,10 @@ struct RealtimeTopListCard: View { let chartData = TopListChartData( item: selectedItem, metric: .views, - items: data.items.map { TopListChartData.Item(current: $0, previous: nil) }, + items: data.items.map { + let itemID = TopListChartData.ItemID(type: selectedItem, id: $0.id) + return TopListChartData.Item(id: itemID, current: $0, previous: nil) + }, maxValue: viewModel.maxValue, ) diff --git a/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift b/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift index a2eb7ffe0c79..7e9c6f2a967c 100644 --- a/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift +++ b/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift @@ -109,7 +109,6 @@ final class TopListCardViewModel: ObservableObject, TrafficCardViewModel { // Cancel stale timer and reset stale flag when data is successfully loaded staleTimer?.cancel() isStale = false - matchedData = data } catch is CancellationError { return @@ -144,7 +143,8 @@ final class TopListCardViewModel: ObservableObject, TrafficCardViewModel { // Match current items with their previous counterparts let matchedItems = current.items.map { currentItem in let previousItem = previous.items.first { $0.id == currentItem.id } - return TopListChartData.Item(current: currentItem, previous: previousItem) + let itemID = TopListChartData.ItemID(type: selection.item, id: currentItem.id) + return TopListChartData.Item(id: itemID, current: currentItem, previous: previousItem) } // Calculate max value from current items based on selected metric diff --git a/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift b/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift index fffc1d3cf3d5..6cd0a54ef96b 100644 --- a/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift +++ b/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift @@ -1,11 +1,21 @@ import Foundation -struct TopListChartData { - struct Item { +final class TopListChartData { + struct Item: Identifiable { + let id: ItemID let current: any TopListItem let previous: (any TopListItem)? } + /// - warning: It's required for animations in ``TopListItemsView`` to work + /// well for IDs to be unique across the domains. If we were just to use + /// `String`, there would be collisions across domains, e.g. post and author + /// using the same String ID "1". + struct ItemID: Hashable { + let type: TopListItemType + let id: String + } + let item: TopListItemType let metric: SiteMetric let items: [Item] @@ -19,21 +29,29 @@ struct TopListChartData { var listID: ListID { ListID(item: item, metric: metric) } + + init(item: TopListItemType, metric: SiteMetric, items: [Item], maxValue: Int) { + self.item = item + self.metric = metric + self.items = items + self.maxValue = maxValue + } } // MARK: - Mock Data extension TopListChartData { static func mock( - for item: TopListItemType, + for itemType: TopListItemType, metric: SiteMetric = .views, itemCount: Int = 6 ) -> TopListChartData { - let items = mockItems(for: item, metric: metric, count: itemCount) + let items = mockItems(for: itemType, metric: metric, count: itemCount) let matchedItems = items.map { item in // Create previous item with slightly different values let previousItem = mockPreviousItem(from: item, metric: metric) - return Item(current: item, previous: previousItem) + let itemID = ItemID(type: itemType, id: item.id) + return Item(id: itemID, current: item, previous: previousItem) } let maxValue = items @@ -41,7 +59,7 @@ extension TopListChartData { .max() ?? 1 return TopListChartData( - item: item, + item: itemType, metric: metric, items: matchedItems, maxValue: maxValue diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListItemBarBackground.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListItemBarBackground.swift index 39d756779f7e..34261e77a1dd 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListItemBarBackground.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemBarBackground.swift @@ -13,7 +13,6 @@ struct TopListItemBarBackground: View { RoundedRectangle(cornerRadius: 6) .fill(barColor.opacity(colorScheme == .light ? 0.09 : 0.25)) .frame(width: barWidth(in: geometry)) - .animation(.spring(response: 0.4, dampingFraction: 0.8), value: value) Spacer(minLength: 0) } } diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift index 89cb196c4221..6bc8cb0eb38e 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift @@ -7,7 +7,7 @@ struct TopListItemsView: View { var body: some View { VStack(spacing: Constants.step1 / 2) { - ForEach(data.items.prefix(itemLimit), id: \.current.id) { item in + ForEach(data.items.prefix(itemLimit)) { item in TopListItemView( currentItem: item.current, previousItem: item.previous, @@ -15,9 +15,11 @@ struct TopListItemsView: View { maxValue: data.maxValue, showDetails: showDetails ) - .transition(.opacity) + .transition(.move(edge: .leading) + .combined(with: .scale(scale: 0.75)) + .combined(with: .opacity)) } } - .animation(.spring, value: data.items.map(\.current.id)) + .animation(.spring, value: ObjectIdentifier(data)) } } diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListMetricsView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListMetricsView.swift index b055c3b3f992..03670638895c 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListMetricsView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListMetricsView.swift @@ -28,7 +28,7 @@ struct TopListMetricsView: View { .padding(.trailing, -2) } } - + .animation(.spring, value: trend) } private var trend: TrendViewModel? { @@ -37,9 +37,4 @@ struct TopListMetricsView: View { } return TrendViewModel(currentValue: currentValue, previousValue: previousValue, metric: metric) } - - private struct ID: Hashable { - let currentValue: Int - let previousValue: Int? - } } From da62f5f536e90b2566e312d618d2b4b2d9e2b6be Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Tue, 22 Jul 2025 11:26:42 -0400 Subject: [PATCH 016/349] Rework how posts/page are rendered --- .../JetpackStats/Cards/TopListCard.swift | 1 + .../JetpackStats/Screens/TrafficTabView.swift | 10 +++---- .../JetpackStats/Services/StatsService.swift | 2 +- .../TopList/Rows/TopListPostRowView.swift | 29 +++++++------------ .../Views/TopList/TopListMetricsView.swift | 17 ++++------- 5 files changed, 23 insertions(+), 36 deletions(-) diff --git a/Modules/Sources/JetpackStats/Cards/TopListCard.swift b/Modules/Sources/JetpackStats/Cards/TopListCard.swift index e7a98b20580a..40f75dc6cf0b 100644 --- a/Modules/Sources/JetpackStats/Cards/TopListCard.swift +++ b/Modules/Sources/JetpackStats/Cards/TopListCard.swift @@ -31,6 +31,7 @@ struct TopListCard: View { } .grayscale(viewModel.isStale ? 1 : 0) .animation(.smooth, value: viewModel.isStale) + .animation(.spring, value: viewModel.matchedData.map(ObjectIdentifier.init)) // placing is important } private var headerView: some View { diff --git a/Modules/Sources/JetpackStats/Screens/TrafficTabView.swift b/Modules/Sources/JetpackStats/Screens/TrafficTabView.swift index ab98b2841f18..ade75c26bfe5 100644 --- a/Modules/Sources/JetpackStats/Screens/TrafficTabView.swift +++ b/Modules/Sources/JetpackStats/Screens/TrafficTabView.swift @@ -64,11 +64,11 @@ struct TrafficTabView: View { return } viewModels = [ - ChartCardViewModel( - metrics: context.service.supportedMetrics, - dateRange: dateRange, - service: context.service - ), +// ChartCardViewModel( +// metrics: context.service.supportedMetrics, +// dateRange: dateRange, +// service: context.service +// ), TopListCardViewModel( selection: .init(item: .postsAndPages, metric: .views), dateRange: dateRange, diff --git a/Modules/Sources/JetpackStats/Services/StatsService.swift b/Modules/Sources/JetpackStats/Services/StatsService.swift index d8eb7147f57d..d54ab4b53d40 100644 --- a/Modules/Sources/JetpackStats/Services/StatsService.swift +++ b/Modules/Sources/JetpackStats/Services/StatsService.swift @@ -125,7 +125,7 @@ actor StatsService: StatsServiceProtocol { case .postsAndPages: switch metric { case .views: - let data = try await getData(StatsTopPostsTimeIntervalData.self) + let data = try await getData(StatsTopPostsTimeIntervalData.self, parameters: ["skip_archives": "1"]) return mapPostsToTopListData(data) case .comments: fatalError() diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListPostRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListPostRowView.swift index 46e9936b75ac..3233d0e226ac 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListPostRowView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListPostRowView.swift @@ -7,25 +7,18 @@ struct TopListPostRowView: View { var body: some View { VStack(alignment: .leading, spacing: 2) { - Text(item.title) - .font(.callout) - .foregroundColor(.primary) - .lineLimit(1) - - if showDetails { - if let author = item.author { - Text(verbatim: author) - .font(.caption) - .foregroundColor(.secondary) - .lineLimit(1) - } - if let date = item.date { - Text(verbatim: date.toMediumString()) - .font(.caption) - .foregroundColor(.secondary) - .lineLimit(1) - } + ZStack { + // Ensure stable height + Text(item.title) + .lineLimit(2, reservesSpace: true) + .opacity(0) + Text(item.title) } + .font(.callout) + .foregroundColor(.primary) + .lineSpacing(-3) + .lineLimit(2) + .padding(.trailing, 4) } } } diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListMetricsView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListMetricsView.swift index 03670638895c..8c14244b974a 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListMetricsView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListMetricsView.swift @@ -14,18 +14,11 @@ struct TopListMetricsView: View { .contentTransition(.numericText()) if showDetails, let trend { - HStack(alignment: .center, spacing: 4) { - HStack(spacing: 0) { - Image(systemName: trend.systemImage) - .font(.caption2.weight(.medium)) - .scaleEffect(x: 0.8, y: 0.8) - Text("\(trend.formattedPercentage)") - } - } - .font(.caption.weight(.medium)).tracking(-0.33) - .foregroundColor(trend.sentiment.foregroundColor) - .contentTransition(.numericText()) - .padding(.trailing, -2) + Text(trend.formattedTrend) + .fixedSize() + .foregroundColor(trend.sentiment.foregroundColor) + .contentTransition(.numericText()) + .font(.caption.weight(.medium)).tracking(-0.33) } } .animation(.spring, value: trend) From acefecdd798758b709c87062c281e84804ae1575 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Tue, 22 Jul 2025 12:26:50 -0400 Subject: [PATCH 017/349] Remove separate Posts and Pages item types --- .../Cards/RealtimeTopListCard.swift | 2 +- .../HistoricalData/historical-pages.json | 72 ----------------- ...sts.json => historical-postsAndPages.json} | 72 ++++++++++++++++- .../Mocks/RealtimeData/realtime-pages.json | 80 ------------------- ...posts.json => realtime-postsAndPages.json} | 78 ++++++++++++++++++ .../Services/Data/TopListChartData.swift | 2 +- .../Services/Data/TopListItemType.swift | 8 +- .../Services/Mocks/MockStatsService.swift | 34 +++----- .../JetpackStats/Services/StatsService.swift | 22 +---- 9 files changed, 163 insertions(+), 207 deletions(-) delete mode 100644 Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-pages.json rename Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/{historical-posts.json => historical-postsAndPages.json} (74%) delete mode 100644 Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-pages.json rename Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/{realtime-posts.json => realtime-postsAndPages.json} (61%) diff --git a/Modules/Sources/JetpackStats/Cards/RealtimeTopListCard.swift b/Modules/Sources/JetpackStats/Cards/RealtimeTopListCard.swift index 65f2484fe1ae..3b2feb768646 100644 --- a/Modules/Sources/JetpackStats/Cards/RealtimeTopListCard.swift +++ b/Modules/Sources/JetpackStats/Cards/RealtimeTopListCard.swift @@ -131,7 +131,7 @@ struct RealtimeTopListCard: View { VStack(spacing: 20) { // Posts & Pages RealtimeTopListCard( - availableDataTypes: [.postsAndPages, .posts, .pages], + availableDataTypes: [.postsAndPages], initialDataType: .postsAndPages, service: MockStatsService() ) diff --git a/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-pages.json b/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-pages.json deleted file mode 100644 index b603d157f74f..000000000000 --- a/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-pages.json +++ /dev/null @@ -1,72 +0,0 @@ -[ - { - "title": "About The Verge", - "pageId": "1", - "type": "page", - "author": "Alex Johnson", - "metrics": { - "views": 900, - "comments": 0, - "likes": 25, - "visitors": 720, - "bounceRate": 60, - "timeOnSite": 90 - } - }, - { - "title": "Contact Us", - "pageId": "2", - "type": "page", - "author": "Chloe Zhang", - "metrics": { - "views": 500, - "comments": 0, - "likes": 10, - "visitors": 400, - "bounceRate": 65, - "timeOnSite": 75 - } - }, - { - "title": "Newsletter Signup", - "pageId": "3", - "type": "page", - "author": "Jordan Davis", - "metrics": { - "views": 400, - "comments": 0, - "likes": 15, - "visitors": 320, - "bounceRate": 55, - "timeOnSite": 120 - } - }, - { - "title": "Privacy Policy", - "pageId": "4", - "type": "page", - "author": "Sofia Rodriguez", - "metrics": { - "views": 250, - "comments": 0, - "likes": 0, - "visitors": 200, - "bounceRate": 70, - "timeOnSite": 60 - } - }, - { - "title": "Terms of Service", - "pageId": "5", - "type": "page", - "author": "Morgan Smith", - "metrics": { - "views": 180, - "comments": 0, - "likes": 0, - "visitors": 144, - "bounceRate": 72, - "timeOnSite": 55 - } - } -] diff --git a/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-posts.json b/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-postsAndPages.json similarity index 74% rename from Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-posts.json rename to Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-postsAndPages.json index b0536588dec6..7135cd064441 100644 --- a/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-posts.json +++ b/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-postsAndPages.json @@ -125,6 +125,20 @@ "timeOnSite": 175 } }, + { + "title": "About The Verge", + "pageId": "1", + "type": "page", + "author": "Alex Johnson", + "metrics": { + "views": 900, + "comments": 0, + "likes": 25, + "visitors": 720, + "bounceRate": 60, + "timeOnSite": 90 + } + }, { "title": "Nothing Phone (2a): Now With 50% More Nothing", "postId": "10", @@ -166,5 +180,61 @@ "bounceRate": 52, "timeOnSite": 145 } + }, + { + "title": "Contact Us", + "pageId": "2", + "type": "page", + "author": "Chloe Zhang", + "metrics": { + "views": 500, + "comments": 0, + "likes": 10, + "visitors": 400, + "bounceRate": 65, + "timeOnSite": 75 + } + }, + { + "title": "Newsletter Signup", + "pageId": "3", + "type": "page", + "author": "Jordan Davis", + "metrics": { + "views": 400, + "comments": 0, + "likes": 15, + "visitors": 320, + "bounceRate": 55, + "timeOnSite": 120 + } + }, + { + "title": "Privacy Policy", + "pageId": "4", + "type": "page", + "author": "Sofia Rodriguez", + "metrics": { + "views": 250, + "comments": 0, + "likes": 0, + "visitors": 200, + "bounceRate": 70, + "timeOnSite": 60 + } + }, + { + "title": "Terms of Service", + "pageId": "5", + "type": "page", + "author": "Morgan Smith", + "metrics": { + "views": 180, + "comments": 0, + "likes": 0, + "visitors": 144, + "bounceRate": 72, + "timeOnSite": 55 + } } -] +] \ No newline at end of file diff --git a/Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-pages.json b/Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-pages.json deleted file mode 100644 index cd3268e9429a..000000000000 --- a/Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-pages.json +++ /dev/null @@ -1,80 +0,0 @@ -[ - { - "pageId": "rtp1", - "title": "About The Verge", - "author": "Editorial Team", - "metrics": { - "views": 120, - "comments": 0, - "likes": 15, - "visitors": 96, - "bounceRate": 60, - "timeOnSite": 90 - } - }, - { - "pageId": "rtp2", - "title": "Contact Us", - "author": "Support Team", - "metrics": { - "views": 95, - "comments": 0, - "likes": 8, - "visitors": 76, - "bounceRate": 65, - "timeOnSite": 75 - } - }, - { - "pageId": "rtp3", - "title": "Newsletter Signup", - "author": "Marketing Team", - "metrics": { - "views": 80, - "comments": 2, - "likes": 12, - "visitors": 64, - "bounceRate": 55, - "timeOnSite": 120 - } - }, - { - "pageId": "rtp4", - "title": "Privacy Policy", - "author": "Legal Team", - "metrics": { - "views": 65, - "comments": 1, - "likes": 5, - "visitors": 52, - "bounceRate": 70, - "timeOnSite": 60 - } - }, - { - "pageId": "rtp5", - "title": "Terms of Service", - "author": "Legal Team", - "metrics": { - "views": 55, - "comments": 0, - "likes": 3, - "visitors": 44, - "bounceRate": 72, - "timeOnSite": 55 - } - }, - { - "pageId": "rtp6", - "title": "Advertise with Us", - "author": "Sales Team", - "metrics": { - "views": 45, - "comments": 0, - "likes": 2, - "visitors": 36, - "bounceRate": 58, - "timeOnSite": 110 - } - } -] \ No newline at end of file diff --git a/Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-posts.json b/Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-postsAndPages.json similarity index 61% rename from Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-posts.json rename to Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-postsAndPages.json index d8cf4944286b..010ab6ec8d7a 100644 --- a/Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-posts.json +++ b/Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-postsAndPages.json @@ -79,6 +79,19 @@ "likes": 42 } }, + { + "pageId": "rtp1", + "title": "About The Verge", + "author": "Editorial Team", + "metrics": { + "views": 120, + "comments": 0, + "likes": 15, + "visitors": 96, + "bounceRate": 60, + "timeOnSite": 90 + } + }, { "postId": "rt9", "title": "Nothing Phone (2) Review", @@ -99,6 +112,19 @@ "likes": 28 } }, + { + "pageId": "rtp2", + "title": "Contact Us", + "author": "Support Team", + "metrics": { + "views": 95, + "comments": 0, + "likes": 8, + "visitors": 76, + "bounceRate": 65, + "timeOnSite": 75 + } + }, { "postId": "rt11", "title": "iPhone 15 Pro Max: Six Months Later", @@ -109,6 +135,19 @@ "likes": 22 } }, + { + "pageId": "rtp3", + "title": "Newsletter Signup", + "author": "Marketing Team", + "metrics": { + "views": 80, + "comments": 2, + "likes": 12, + "visitors": 64, + "bounceRate": 55, + "timeOnSite": 120 + } + }, { "postId": "rt12", "title": "The Best Smart Home Devices of 2024", @@ -118,5 +157,44 @@ "comments": 6, "likes": 18 } + }, + { + "pageId": "rtp4", + "title": "Privacy Policy", + "author": "Legal Team", + "metrics": { + "views": 65, + "comments": 1, + "likes": 5, + "visitors": 52, + "bounceRate": 70, + "timeOnSite": 60 + } + }, + { + "pageId": "rtp5", + "title": "Terms of Service", + "author": "Legal Team", + "metrics": { + "views": 55, + "comments": 0, + "likes": 3, + "visitors": 44, + "bounceRate": 72, + "timeOnSite": 55 + } + }, + { + "pageId": "rtp6", + "title": "Advertise with Us", + "author": "Sales Team", + "metrics": { + "views": 45, + "comments": 0, + "likes": 2, + "visitors": 36, + "bounceRate": 58, + "timeOnSite": 110 + } } ] \ No newline at end of file diff --git a/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift b/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift index 6cd0a54ef96b..b43b24a948f0 100644 --- a/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift +++ b/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift @@ -72,7 +72,7 @@ extension TopListChartData { count: Int ) -> [any TopListItem] { switch item { - case .postsAndPages, .posts, .pages: + case .postsAndPages: return mockPosts(metric: metric, count: count) case .referrers: return mockReferrers(metric: metric, count: count) diff --git a/Modules/Sources/JetpackStats/Services/Data/TopListItemType.swift b/Modules/Sources/JetpackStats/Services/Data/TopListItemType.swift index 2278d9bcc37e..949b2d88be56 100644 --- a/Modules/Sources/JetpackStats/Services/Data/TopListItemType.swift +++ b/Modules/Sources/JetpackStats/Services/Data/TopListItemType.swift @@ -2,8 +2,6 @@ import SwiftUI enum TopListItemType: Identifiable, CaseIterable, Sendable { case postsAndPages - case posts - case pages case authors case referrers case locations @@ -17,8 +15,6 @@ enum TopListItemType: Identifiable, CaseIterable, Sendable { var localizedTitle: String { switch self { case .postsAndPages: Strings.SiteDataTypes.postsAndPages - case .posts: Strings.SiteDataTypes.posts - case .pages: Strings.SiteDataTypes.pages case .authors: Strings.SiteDataTypes.authors case .referrers: Strings.SiteDataTypes.referrers case .locations: Strings.SiteDataTypes.locations @@ -31,9 +27,7 @@ enum TopListItemType: Identifiable, CaseIterable, Sendable { var systemImage: String { switch self { - case .postsAndPages: "doc.on.doc" - case .posts: "doc.text" - case .pages: "doc" + case .postsAndPages: "doc.text" case .referrers: "link" case .locations: "map" case .authors: "person.2" diff --git a/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift b/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift index ac0a5bdf55ba..8b211ac1c5e5 100644 --- a/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift +++ b/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift @@ -11,7 +11,7 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { nonisolated func getSupportedMetrics(for item: TopListItemType) -> [SiteMetric] { switch item { - case .postsAndPages, .posts, .pages: [.views, .visitors, .comments, .likes] + case .postsAndPages: [.views, .visitors, .comments, .likes] case .referrers: [.views, .visitors] case .locations: [.views, .visitors] case .authors: [.views, .comments, .likes] @@ -172,15 +172,8 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { private func loadRealtimeBaseItems(for dataType: TopListItemType) -> [any TopListItem] { let fileName: String switch dataType { - case .posts: - fileName = "posts" - case .pages: - fileName = "pages" case .postsAndPages: - // Load both posts and pages - let posts = loadRealtimeBaseItems(for: .posts) - let pages = loadRealtimeBaseItems(for: .pages) - return posts + pages + fileName = "postsAndPages" case .referrers: fileName = "referrers" case .locations: @@ -209,9 +202,6 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { // Decode based on data type switch dataType { - case .posts, .pages: - let posts = try decoder.decode([TopListData.Post].self, from: data) - return posts case .referrers: let referrers = try decoder.decode([TopListData.Referrer].self, from: data) return referrers @@ -240,7 +230,8 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { let videos = try decoder.decode([TopListData.Video].self, from: data) return videos case .postsAndPages: - return [] // Already handled above + let posts = try decoder.decode([TopListData.Post].self, from: data) + return posts } } catch { print("Failed to load \(fileName).json: \(error)") @@ -248,6 +239,7 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { } } + // MARK: - Data Aggregation /// Aggregates raw data into data points based on granularity @@ -274,15 +266,8 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { private func loadHistoricalItems(for dataType: TopListItemType) -> [any TopListItem] { let fileName: String switch dataType { - case .posts: - fileName = "historical-posts" - case .pages: - fileName = "historical-pages" case .postsAndPages: - // Load both posts and pages - let posts = loadHistoricalItems(for: .posts) - let pages = loadHistoricalItems(for: .pages) - return posts + pages + fileName = "historical-postsAndPages" case .referrers: fileName = "historical-referrers" case .locations: @@ -311,9 +296,6 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { // Decode based on data type switch dataType { - case .posts, .pages: - let posts = try decoder.decode([TopListData.Post].self, from: data) - return posts case .referrers: let referrers = try decoder.decode([TopListData.Referrer].self, from: data) return referrers @@ -342,7 +324,8 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { let videos = try decoder.decode([TopListData.Video].self, from: data) return videos case .postsAndPages: - return [] // Already handled above + let posts = try decoder.decode([TopListData.Post].self, from: data) + return posts } } catch { print("Failed to load \(fileName).json: \(error)") @@ -350,6 +333,7 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { } } + // MARK: - Data Generation /// Mutates item metrics based on growth factors and variations diff --git a/Modules/Sources/JetpackStats/Services/StatsService.swift b/Modules/Sources/JetpackStats/Services/StatsService.swift index d54ab4b53d40..29b0627d145e 100644 --- a/Modules/Sources/JetpackStats/Services/StatsService.swift +++ b/Modules/Sources/JetpackStats/Services/StatsService.swift @@ -24,13 +24,13 @@ actor StatsService: StatsServiceProtocol { ] let supportedItems: [TopListItemType] = [ - .postsAndPages, .posts, .pages, .referrers, .locations, .authors, .externalLinks, + .postsAndPages, .referrers, .locations, .authors, .externalLinks, .fileDownloads, .searchTerms, .videos ] nonisolated func getSupportedMetrics(for item: TopListItemType) -> [SiteMetric] { switch item { - case .postsAndPages, .posts, .pages: [.views] + case .postsAndPages: [.views] case .referrers: [.views] case .locations: [.views] case .authors: [.views] @@ -133,24 +133,6 @@ actor StatsService: StatsServiceProtocol { throw StatsServiceError.unavailable } - case .pages: - switch metric { - case .views: - let data = try await getData(StatsTopPostsTimeIntervalData.self) - return mapPostsToTopListData(data, filterKind: .page) - default: - throw StatsServiceError.unavailable - } - - case .posts: - switch metric { - case .views: - let data = try await getData(StatsTopPostsTimeIntervalData.self) - return mapPostsToTopListData(data, filterKind: .post) - default: - throw StatsServiceError.unavailable - } - case .referrers: let data = try await getData(StatsTopReferrersTimeIntervalData.self) return mapReferrersToTopListData(data) From aac71cdbfe9c791c039e4ff632066513f9bd77bf Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Tue, 22 Jul 2025 12:53:31 -0400 Subject: [PATCH 018/349] Add ChartCardViewModel back --- .../Sources/JetpackStats/Screens/TrafficTabView.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Modules/Sources/JetpackStats/Screens/TrafficTabView.swift b/Modules/Sources/JetpackStats/Screens/TrafficTabView.swift index ade75c26bfe5..ab98b2841f18 100644 --- a/Modules/Sources/JetpackStats/Screens/TrafficTabView.swift +++ b/Modules/Sources/JetpackStats/Screens/TrafficTabView.swift @@ -64,11 +64,11 @@ struct TrafficTabView: View { return } viewModels = [ -// ChartCardViewModel( -// metrics: context.service.supportedMetrics, -// dateRange: dateRange, -// service: context.service -// ), + ChartCardViewModel( + metrics: context.service.supportedMetrics, + dateRange: dateRange, + service: context.service + ), TopListCardViewModel( selection: .init(item: .postsAndPages, metric: .views), dateRange: dateRange, From 194b9d1edfa57373e2ce38d4981c9015bd7f551b Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Tue, 22 Jul 2025 13:05:36 -0400 Subject: [PATCH 019/349] Add chevron for posts --- .../Views/TopList/TopListItemView.swift | 5 +++-- .../Views/TopList/TopListMetricsView.swift | 17 ++++++++++++----- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift index 80a68a6f8eaa..0bbe1a8ab459 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift @@ -37,9 +37,10 @@ struct TopListItemView: View { // Metrics view TopListMetricsView( currentValue: currentItem.metrics[metric] ?? 0, - previousValue: previousItem?.metrics[metric] ?? 0, + previousValue: previousItem?.metrics[metric], metric: metric, - showDetails: showDetails + showDetails: showDetails, + showChevron: currentItem is TopListData.Post ) } .padding(.vertical, 7) diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListMetricsView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListMetricsView.swift index 8c14244b974a..f97591173434 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListMetricsView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListMetricsView.swift @@ -4,15 +4,22 @@ struct TopListMetricsView: View { let currentValue: Int let previousValue: Int? let metric: SiteMetric - let showDetails: Bool + var showDetails = true + var showChevron = false var body: some View { VStack(alignment: .trailing, spacing: 2) { - Text(StatsValueFormatter.formatNumber(currentValue, onlyLarge: true)) - .font(.subheadline.weight(.medium)).tracking(-0.1) - .foregroundColor(.primary) - .contentTransition(.numericText()) + HStack(spacing: 3) { + Text(StatsValueFormatter.formatNumber(currentValue, onlyLarge: true)) + .font(.subheadline.weight(.medium)).tracking(-0.1) + .foregroundColor(.primary) + .contentTransition(.numericText()) + Image(systemName: "chevron.forward") + .font(.caption2.weight(.bold)) + .foregroundStyle(Color(.tertiaryLabel)) + .padding(.trailing, -2) + } if showDetails, let trend { Text(trend.formattedTrend) .fixedSize() From 23f993f507d2a2851e1822c5d07462a6ef7220ce Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Tue, 22 Jul 2025 15:12:46 -0400 Subject: [PATCH 020/349] Add PostStatsDetailsView --- Modules/Package.resolved | 4 ++-- Modules/Package.swift | 2 +- .../JetpackStats/Screens/PostStatsDetailsView.swift | 13 +++++++++++++ 3 files changed, 16 insertions(+), 3 deletions(-) create mode 100644 Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift diff --git a/Modules/Package.resolved b/Modules/Package.resolved index d91bdbcbdda5..0af61efbf05e 100644 --- a/Modules/Package.resolved +++ b/Modules/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "53f781e3200e2cdd0d592e9fd8be54771b128e996dfb0d5d650ea5dd3688fa63", + "originHash" : "d48cb9cb3ebb25017a12e4a4287cf96059b7b9ac1ef981a715a35a38934bb9a4", "pins" : [ { "identity" : "alamofire", @@ -381,7 +381,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/wordpress-mobile/WordPressKit-iOS", "state" : { - "revision" : "392d2d34356ba5ea7ea2e01b32f55cc261fdf769" + "revision" : "9c20c9934094c34797f32ed1e75aa10af1437a1c" } }, { diff --git a/Modules/Package.swift b/Modules/Package.swift index de364a2e83ea..737a48b669c9 100644 --- a/Modules/Package.swift +++ b/Modules/Package.swift @@ -50,7 +50,7 @@ let package = Package( .package(url: "https://github.com/wordpress-mobile/NSURL-IDN", revision: "b34794c9a3f32312e1593d4a3d120572afa0d010"), .package( url: "https://github.com/wordpress-mobile/WordPressKit-iOS", - revision: "392d2d34356ba5ea7ea2e01b32f55cc261fdf769" // see wpios-edition branch + revision: "9c20c9934094c34797f32ed1e75aa10af1437a1c" // see wpios-edition branch ), .package(url: "https://github.com/zendesk/support_sdk_ios", from: "8.0.3"), // We can't use wordpress-rs branches nor commits here. Only tags work. diff --git a/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift b/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift new file mode 100644 index 000000000000..6ca5b51265f3 --- /dev/null +++ b/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift @@ -0,0 +1,13 @@ +import SwiftUI + +struct PostStatsDetailsView: View { + let post: TopListData.Post + + var body: some View { + Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + } +} + +#Preview { + PostStatsDetailsView() +} From a19a583814226872b7d6fb22a3ae437a90821c0e Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Tue, 22 Jul 2025 15:36:20 -0400 Subject: [PATCH 021/349] Add initial PostStatsDetailsView --- Modules/Package.resolved | 4 +- Modules/Package.swift | 2 +- Modules/Sources/JetpackStats/Constants.swift | 1 + .../historical-postsAndPages.json | 14 +- .../Resources/Mocks/Misc/post-details.json | 152 ++++++++++++++++++ .../Screens/PostStatsDetailsView.swift | 145 ++++++++++++++++- .../Services/Mocks/MockStatsService.swift | 19 +++ .../JetpackStats/Services/StatsService.swift | 4 + .../Services/StatsServiceProtocol.swift | 2 + Modules/Sources/JetpackStats/Strings.swift | 11 ++ .../TopList/Rows/TopListPostRowView.swift | 1 - .../Views/TopList/TopListMetricsView.swift | 1 - 12 files changed, 342 insertions(+), 14 deletions(-) create mode 100644 Modules/Sources/JetpackStats/Resources/Mocks/Misc/post-details.json diff --git a/Modules/Package.resolved b/Modules/Package.resolved index 0af61efbf05e..2d6028c5661a 100644 --- a/Modules/Package.resolved +++ b/Modules/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "d48cb9cb3ebb25017a12e4a4287cf96059b7b9ac1ef981a715a35a38934bb9a4", + "originHash" : "e47922a7de43ee329974363b3d3a014043606f9384f9d8db505b1b98828827c5", "pins" : [ { "identity" : "alamofire", @@ -381,7 +381,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/wordpress-mobile/WordPressKit-iOS", "state" : { - "revision" : "9c20c9934094c34797f32ed1e75aa10af1437a1c" + "revision" : "32f941002c04f431f4c8de072bdc898401493500" } }, { diff --git a/Modules/Package.swift b/Modules/Package.swift index 737a48b669c9..b39ea4af1b57 100644 --- a/Modules/Package.swift +++ b/Modules/Package.swift @@ -50,7 +50,7 @@ let package = Package( .package(url: "https://github.com/wordpress-mobile/NSURL-IDN", revision: "b34794c9a3f32312e1593d4a3d120572afa0d010"), .package( url: "https://github.com/wordpress-mobile/WordPressKit-iOS", - revision: "9c20c9934094c34797f32ed1e75aa10af1437a1c" // see wpios-edition branch + revision: "32f941002c04f431f4c8de072bdc898401493500" // see wpios-edition branch ), .package(url: "https://github.com/zendesk/support_sdk_ios", from: "8.0.3"), // We can't use wordpress-rs branches nor commits here. Only tags work. diff --git a/Modules/Sources/JetpackStats/Constants.swift b/Modules/Sources/JetpackStats/Constants.swift index 5445b5602ebd..9355fc591c9c 100644 --- a/Modules/Sources/JetpackStats/Constants.swift +++ b/Modules/Sources/JetpackStats/Constants.swift @@ -40,6 +40,7 @@ enum Constants { static let step1: CGFloat = 12 static let step2: CGFloat = 18 static let step3: CGFloat = 24 + static let step4: CGFloat = 32 } private extension Color { diff --git a/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-postsAndPages.json b/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-postsAndPages.json index 7135cd064441..10159bc7b626 100644 --- a/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-postsAndPages.json +++ b/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-postsAndPages.json @@ -14,7 +14,7 @@ } }, { - "title": "I Shattered the Apple Vision Pro's Front Glass and It Only Cost Me $799 to Fix", + "title": "I Replaced Every Component in My Framework Laptop Until It Became a Desktop", "postId": "2", "type": "post", "author": "Alex Johnson", @@ -42,7 +42,7 @@ } }, { - "title": "Google's Pixel 8 Pro Has Too Many Cameras (I Counted)", + "title": "Matter Smart Home Protocol Still Doesn't Matter: A Year Later", "postId": "4", "type": "post", "author": "Jordan Davis", @@ -70,7 +70,7 @@ } }, { - "title": "Microsoft's New AI Can Predict When You'll Rage Quit Excel", + "title": "Nothing Phone (2a): Now With 50% More Nothing", "postId": "6", "type": "post", "author": "Morgan Smith", @@ -140,7 +140,7 @@ } }, { - "title": "Nothing Phone (2a): Now With 50% More Nothing", + "title": "Microsoft's New AI Can Predict When You'll Rage Quit Excel", "postId": "10", "type": "post", "author": "Jamie Lee", @@ -154,7 +154,7 @@ } }, { - "title": "I Replaced Every Component in My Framework Laptop Until It Became a Desktop", + "title": "I Shattered the Apple Vision Pro's Front Glass and It Only Cost Me $799 to Fix", "postId": "11", "type": "post", "author": "Sofia Rodriguez", @@ -168,7 +168,7 @@ } }, { - "title": "Matter Smart Home Protocol Still Doesn't Matter: A Year Later", + "title": "Google's Pixel 8 Pro Has Too Many Cameras (I Counted)", "postId": "12", "type": "post", "author": "Avery Taylor", @@ -237,4 +237,4 @@ "timeOnSite": 55 } } -] \ No newline at end of file +] diff --git a/Modules/Sources/JetpackStats/Resources/Mocks/Misc/post-details.json b/Modules/Sources/JetpackStats/Resources/Mocks/Misc/post-details.json new file mode 100644 index 000000000000..7fdab1e52797 --- /dev/null +++ b/Modules/Sources/JetpackStats/Resources/Mocks/Misc/post-details.json @@ -0,0 +1,152 @@ +{ + "date": "2025-01-22", + "views": 45892, + "highest_month": 3248, + "highest_day_average": 147, + "highest_week_average": 892, + "fields": ["views", "visitors", "likes", "comments"], + "years": { + "2025": { + "total": 2847, + "months": { + "1": 2847 + } + }, + "2024": { + "total": 43045, + "months": { + "1": 2156, + "2": 3248, + "3": 2987, + "4": 3156, + "5": 3421, + "6": 3087, + "7": 3654, + "8": 3892, + "9": 3421, + "10": 3987, + "11": 4156, + "12": 3880 + } + } + }, + "averages": { + "2025": { + "overall": 92, + "months": { + "1": 92 + } + }, + "2024": { + "overall": 118, + "months": { + "1": 70, + "2": 112, + "3": 96, + "4": 105, + "5": 110, + "6": 103, + "7": 118, + "8": 125, + "9": 114, + "10": 129, + "11": 139, + "12": 125 + } + } + }, + "weeks": [ + { + "total": 892, + "average": 127, + "change": 15.2, + "days": [ + { "day": "2025-01-15", "count": 98 }, + { "day": "2025-01-16", "count": 134 }, + { "day": "2025-01-17", "count": 156 }, + { "day": "2025-01-18", "count": 89 }, + { "day": "2025-01-19", "count": 102 }, + { "day": "2025-01-20", "count": 178 }, + { "day": "2025-01-21", "count": 135 } + ] + }, + { + "total": 774, + "average": 111, + "change": -8.7, + "days": [ + { "day": "2025-01-08", "count": 89 }, + { "day": "2025-01-09", "count": 125 }, + { "day": "2025-01-10", "count": 134 }, + { "day": "2025-01-11", "count": 78 }, + { "day": "2025-01-12", "count": 95 }, + { "day": "2025-01-13", "count": 145 }, + { "day": "2025-01-14", "count": 108 } + ] + }, + { + "total": 847, + "average": 121, + "change": { "isInfinity": true }, + "days": [ + { "day": "2025-01-01", "count": 45 }, + { "day": "2025-01-02", "count": 112 }, + { "day": "2025-01-03", "count": 156 }, + { "day": "2025-01-04", "count": 134 }, + { "day": "2025-01-05", "count": 98 }, + { "day": "2025-01-06", "count": 167 }, + { "day": "2025-01-07", "count": 135 } + ] + }, + { + "total": 912, + "average": 130, + "change": 5.4, + "days": [ + { "day": "2024-12-25", "count": 67 }, + { "day": "2024-12-26", "count": 89 }, + { "day": "2024-12-27", "count": 145 }, + { "day": "2024-12-28", "count": 156 }, + { "day": "2024-12-29", "count": 134 }, + { "day": "2024-12-30", "count": 189 }, + { "day": "2024-12-31", "count": 132 } + ] + } + ], + "data": [ + ["2025-01-08", 89], + ["2025-01-09", 125], + ["2025-01-10", 134], + ["2025-01-11", 78], + ["2025-01-12", 95], + ["2025-01-13", 145], + ["2025-01-14", 108], + ["2025-01-15", 98], + ["2025-01-16", 134], + ["2025-01-17", 156], + ["2025-01-18", 89], + ["2025-01-19", 102], + ["2025-01-20", 178], + ["2025-01-21", 135] + ], + "post": { + "ID": 12345, + "post_title": "Apple's Vision Pro is a lonely computer", + "post_author": "42", + "post_date_gmt": "2024-03-15 14:30:00", + "post_content": "

After spending a month with Apple's $3,499 Vision Pro, I've come to a stark realization: this might be the most impressive device I've ever used, and also the loneliest.

\n\n

The Vision Pro does things that feel like magic. It can transform your living room into a personal IMAX theater, let you arrange infinite virtual monitors around your desk, or transport you to Mount Hood while you're sitting on your couch. The eye tracking is so precise it feels telepathic, and the hand gestures are more natural than any interface I've used.

\n\n

The isolation problem

\n

But here's the thing: every moment you spend in the Vision Pro is a moment you're not spending with the people around you. Sure, you can see them through the passthrough cameras, rendered in surprisingly high quality. You can even make eye contact through the bizarre EyeSight display. But you're not really there with them. You're in Apple's meticulously crafted digital bubble, experiencing a world they can't see.

\n\n

This isn't just a Vision Pro problem — it's the fundamental challenge facing all headsets. Meta's Quest 3 has the same issue, though at least it doesn't cost as much as a used car. Even the upcoming Samsung headset will face this reality: strapping a computer to your face is inherently antisocial.

", + "post_excerpt": "The Vision Pro is an incredible piece of technology that fundamentally misunderstands how humans want to use computers.", + "post_status": "publish", + "comment_status": "open", + "post_password": "", + "post_name": "apple-vision-pro-lonely-computer-review", + "post_modified_gmt": "2024-12-20 09:15:00", + "post_content_filtered": "", + "post_parent": 0, + "guid": "https://example.com/?p=12345", + "post_type": "post", + "post_mime_type": "", + "comment_count": "487", + "permalink": "https://example.com/2024/03/apple-vision-pro-lonely-computer-review/" + } +} \ No newline at end of file diff --git a/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift b/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift index 6ca5b51265f3..1208ad53f6fa 100644 --- a/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift +++ b/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift @@ -1,13 +1,154 @@ import SwiftUI +import UIKit +import WordPressKit struct PostStatsDetailsView: View { let post: TopListData.Post + + @Environment(\.context) private var context + @State private var details: StatsPostDetails? + @State private var isLoading = true + @State private var error: Error? + + var body: some View { + ScrollView { + VStack(spacing: Constants.step2) { + if let details { + PostHeaderCard(post: post, details: details, context: context) + .cardStyle() + } else if isLoading { + ProgressView() + .frame(maxWidth: .infinity, minHeight: 200) + .cardStyle() + } else if let error { + SimpleErrorView(error: error) + .frame(maxWidth: .infinity, minHeight: 200) + .cardStyle() + } + } + .padding(.vertical, Constants.step1) + } + .background(Constants.Colors.statsBackground) + .navigationTitle(Strings.PostDetails.title) + .navigationBarTitleDisplayMode(.inline) + .task { + await loadPostDetails() + } + } + + private func loadPostDetails() async { + guard let postId = post.postId, let postIdInt = Int(postId) else { return } + + do { + let details = try await context.service.getPostDetails(for: postIdInt) + self.details = details + self.isLoading = false + } catch { + self.error = error + self.isLoading = false + } + } +} + +private struct PostHeaderCard: View { + let post: TopListData.Post + let details: StatsPostDetails + let context: StatsContext + + var body: some View { + VStack(alignment: .leading, spacing: Constants.step3) { + VStack(alignment: .leading, spacing: 4) { + Text(post.title) + .font(.title3.weight(.semibold)) + .multilineTextAlignment(.leading) + .lineLimit(3) + + if let dateGMT = details.post?.dateGMT { + HStack(spacing: 6) { + Text(Strings.PostDetails.published(formatPublishedDate(dateGMT))) + .font(.subheadline) + .foregroundColor(.secondary) + + // Permalink button + if let permalink = details.post?.permalink, let url = URL(string: permalink) { + Button(action: { + UIApplication.shared.open(url) + }) { + Image(systemName: "link") + .font(.footnote) + .foregroundColor(.accentColor) + } + } + } + } + } + + // Metrics with visual separation + HStack(spacing: Constants.step3) { + MetricView(metric: .views, value: details.totalViewsCount) + + if let likesCount = post.metrics.likes { + MetricView(metric: .likes, value: likesCount) + } + + if let commentCount = details.post?.commentCount, let count = Int(commentCount) { + MetricView(metric: .comments, value: count) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(EdgeInsets(top: Constants.step2, leading: Constants.step2, bottom: Constants.step1, trailing: Constants.step2)) + } + + private func formatPublishedDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .none + formatter.timeZone = context.timeZone + return formatter.string(from: date) + } +} + +private struct MetricView: View { + let metric: SiteMetric + let value: Int var body: some View { - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 2) { + Image(systemName: metric.systemImage) + .font(.caption2.weight(.medium)) + .foregroundColor(.secondary) + + Text(metric.localizedTitle.uppercased()) + .font(.caption.weight(.medium)) + .foregroundColor(.secondary) + } + + Text(StatsValueFormatter(metric: metric).format(value: value)) + .contentTransition(.numericText()) + .animation(.spring, value: value) + .font(Font.make(.recoleta, textStyle: .title, weight: .medium)) + .foregroundColor(.primary) + } + .lineLimit(1) + .frame(minWidth: 78, alignment: .leading) } } #Preview { - PostStatsDetailsView() + NavigationStack { + PostStatsDetailsView( + post: .init( + title: "Apple's Vision Pro is a lonely computer", + postId: "12345", + date: .now, + pageId: nil, + type: "post", + author: nil, + metrics: .init(views: 45892, likes: 26, comments: 487) + ) + ) + .environment(\.context, StatsContext.demo) + } } diff --git a/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift b/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift index 8b211ac1c5e5..81ec6d87e1b9 100644 --- a/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift +++ b/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift @@ -1,5 +1,6 @@ import Foundation import SwiftUI +@preconcurrency import WordPressKit actor MockStatsService: ObservableObject, StatsServiceProtocol { private var hourlyData: [SiteMetric: [DataPoint]] = [:] @@ -239,6 +240,24 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { } } + func getPostDetails(for postID: Int) async throws -> StatsPostDetails { + // Load from JSON file in Mocks/Misc directory + guard let url = Bundle.module.url(forResource: "post-details", withExtension: "json") else { + throw URLError(.fileDoesNotExist) + } + + let data = try Data(contentsOf: url) + let jsonObject = try JSONSerialization.jsonObject(with: data) as! [String: AnyObject] + + // Simulate network delay + try? await Task.sleep(for: .milliseconds(Int.random(in: 200...500))) + + guard let details = StatsPostDetails(jsonDictionary: jsonObject) else { + throw URLError(.cannotParseResponse) + } + + return details + } // MARK: - Data Aggregation diff --git a/Modules/Sources/JetpackStats/Services/StatsService.swift b/Modules/Sources/JetpackStats/Services/StatsService.swift index 29b0627d145e..5715444064e5 100644 --- a/Modules/Sources/JetpackStats/Services/StatsService.swift +++ b/Modules/Sources/JetpackStats/Services/StatsService.swift @@ -187,6 +187,10 @@ actor StatsService: StatsServiceProtocol { try await mocks.getRealtimeTopListData(item) } + func getPostDetails(for postID: Int) async throws -> StatsPostDetails { + try await service.getDetails(forPostID: postID) + } + // MARK: - Dates /// Convert from the site timezone (used in JetpackState) to the local diff --git a/Modules/Sources/JetpackStats/Services/StatsServiceProtocol.swift b/Modules/Sources/JetpackStats/Services/StatsServiceProtocol.swift index a4f569e4bf93..897851f78343 100644 --- a/Modules/Sources/JetpackStats/Services/StatsServiceProtocol.swift +++ b/Modules/Sources/JetpackStats/Services/StatsServiceProtocol.swift @@ -1,4 +1,5 @@ import Foundation +import WordPressKit protocol StatsServiceProtocol: AnyObject, Sendable { var supportedMetrics: [SiteMetric] { get } @@ -9,4 +10,5 @@ protocol StatsServiceProtocol: AnyObject, Sendable { func getSiteStats(interval: DateInterval, granularity: DateRangeGranularity) async throws -> SiteMetricsData func getTopListData(_ item: TopListItemType, metric: SiteMetric, interval: DateInterval, granularity: DateRangeGranularity) async throws -> TopListData func getRealtimeTopListData(_ item: TopListItemType) async throws -> TopListData + func getPostDetails(for postID: Int) async throws -> StatsPostDetails } diff --git a/Modules/Sources/JetpackStats/Strings.swift b/Modules/Sources/JetpackStats/Strings.swift index 004f2bc8289a..d5c4759e232e 100644 --- a/Modules/Sources/JetpackStats/Strings.swift +++ b/Modules/Sources/JetpackStats/Strings.swift @@ -114,4 +114,15 @@ enum Strings { ) } } + + enum PostDetails { + static let title = AppLocalizedString("jetpackStats.postDetails.title", value: "Post Stats", comment: "Navigation title") + static let allTimeStats = AppLocalizedString("jetpackStats.postDetails.allTimeStats", value: "All-time stats", comment: "Header for all-time statistics section") + static func published(_ date: String) -> String { + String.localizedStringWithFormat( + AppLocalizedString("jetpackStats.postDetails.published", value: "Published %1$@", comment: "Shows when the post was published. %1$@ is the formatted date."), + date + ) + } + } } diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListPostRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListPostRowView.swift index 3233d0e226ac..e3683b7e7da8 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListPostRowView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListPostRowView.swift @@ -16,7 +16,6 @@ struct TopListPostRowView: View { } .font(.callout) .foregroundColor(.primary) - .lineSpacing(-3) .lineLimit(2) .padding(.trailing, 4) } diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListMetricsView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListMetricsView.swift index f97591173434..e70079a73820 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListMetricsView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListMetricsView.swift @@ -18,7 +18,6 @@ struct TopListMetricsView: View { Image(systemName: "chevron.forward") .font(.caption2.weight(.bold)) .foregroundStyle(Color(.tertiaryLabel)) - .padding(.trailing, -2) } if showDetails, let trend { Text(trend.formattedTrend) From cfcfe3b23c7b7da3261e6e6e8bf3818ad7e88f28 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Tue, 22 Jul 2025 17:16:43 -0400 Subject: [PATCH 022/349] Add likes strip --- .../Resources/Mocks/Misc/post-details.json | 72 ++- .../Screens/PostStatsDetailsView.swift | 508 +++++++++++++++++- .../Services/Data/PostLikeUser.swift | 23 + .../Services/Mocks/MockStatsService.swift | 48 ++ .../JetpackStats/Services/StatsService.swift | 34 ++ .../Services/StatsServiceProtocol.swift | 1 + Modules/Sources/JetpackStats/Strings.swift | 26 + 7 files changed, 704 insertions(+), 8 deletions(-) create mode 100644 Modules/Sources/JetpackStats/Services/Data/PostLikeUser.swift diff --git a/Modules/Sources/JetpackStats/Resources/Mocks/Misc/post-details.json b/Modules/Sources/JetpackStats/Resources/Mocks/Misc/post-details.json index 7fdab1e52797..8819c30baa6d 100644 --- a/Modules/Sources/JetpackStats/Resources/Mocks/Misc/post-details.json +++ b/Modules/Sources/JetpackStats/Resources/Mocks/Misc/post-details.json @@ -28,6 +28,40 @@ "11": 4156, "12": 3880 } + }, + "2023": { + "total": 28567, + "months": { + "1": 1856, + "2": 2134, + "3": 2567, + "4": 2234, + "5": 2678, + "6": 2456, + "7": 2890, + "8": 2345, + "9": 2123, + "10": 2567, + "11": 2345, + "12": 2372 + } + }, + "2022": { + "total": 15234, + "months": { + "1": 1234, + "2": 1345, + "3": 1456, + "4": 1234, + "5": 1345, + "6": 1123, + "7": 1234, + "8": 1345, + "9": 1234, + "10": 1123, + "11": 1234, + "12": 1327 + } } }, "averages": { @@ -53,6 +87,40 @@ "11": 139, "12": 125 } + }, + "2023": { + "overall": 78, + "months": { + "1": 60, + "2": 76, + "3": 83, + "4": 75, + "5": 86, + "6": 82, + "7": 93, + "8": 76, + "9": 71, + "10": 83, + "11": 78, + "12": 77 + } + }, + "2022": { + "overall": 42, + "months": { + "1": 40, + "2": 48, + "3": 47, + "4": 41, + "5": 43, + "6": 37, + "7": 40, + "8": 43, + "9": 41, + "10": 36, + "11": 41, + "12": 43 + } } }, "weeks": [ @@ -131,7 +199,7 @@ ], "post": { "ID": 12345, - "post_title": "Apple's Vision Pro is a lonely computer", + "post_title": "Matter Smart Home Protocol Still Doesn't Matter: A Year Later", "post_author": "42", "post_date_gmt": "2024-03-15 14:30:00", "post_content": "

After spending a month with Apple's $3,499 Vision Pro, I've come to a stark realization: this might be the most impressive device I've ever used, and also the loneliest.

\n\n

The Vision Pro does things that feel like magic. It can transform your living room into a personal IMAX theater, let you arrange infinite virtual monitors around your desk, or transport you to Mount Hood while you're sitting on your couch. The eye tracking is so precise it feels telepathic, and the hand gestures are more natural than any interface I've used.

\n\n

The isolation problem

\n

But here's the thing: every moment you spend in the Vision Pro is a moment you're not spending with the people around you. Sure, you can see them through the passthrough cameras, rendered in surprisingly high quality. You can even make eye contact through the bizarre EyeSight display. But you're not really there with them. You're in Apple's meticulously crafted digital bubble, experiencing a world they can't see.

\n\n

This isn't just a Vision Pro problem — it's the fundamental challenge facing all headsets. Meta's Quest 3 has the same issue, though at least it doesn't cost as much as a used car. Even the upcoming Samsung headset will face this reality: strapping a computer to your face is inherently antisocial.

", @@ -149,4 +217,4 @@ "comment_count": "487", "permalink": "https://example.com/2024/03/apple-vision-pro-lonely-computer-review/" } -} \ No newline at end of file +} diff --git a/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift b/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift index 1208ad53f6fa..1bc970a4668f 100644 --- a/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift +++ b/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift @@ -7,6 +7,7 @@ struct PostStatsDetailsView: View { @Environment(\.context) private var context @State private var details: StatsPostDetails? + @State private var postLikes: PostLikes? @State private var isLoading = true @State private var error: Error? @@ -14,8 +15,30 @@ struct PostStatsDetailsView: View { ScrollView { VStack(spacing: Constants.step2) { if let details { - PostHeaderCard(post: post, details: details, context: context) + PostHeaderCard(post: post, details: details, postLikes: postLikes, context: context) .cardStyle() + + // Peak Performance Card + if details.highestMonth != nil || details.highestDayAverage != nil || details.highestWeekAverage != nil { + PeakPerformanceCard(details: details) + .cardStyle() + } + + // Weekly Trends Chart + if !details.recentWeeks.isEmpty { + WeeklyTrendsCard(weeks: details.recentWeeks, context: context) + .cardStyle() + } + + // Yearly Summary + if !details.yearlyTotals.isEmpty { + YearlySummaryCard( + yearlyTotals: details.yearlyTotals, + overallAverages: details.overallAverages, + monthlyBreakdown: details.monthlyBreakdown + ) + .cardStyle() + } } else if isLoading { ProgressView() .frame(maxWidth: .infinity, minHeight: 200) @@ -39,9 +62,18 @@ struct PostStatsDetailsView: View { private func loadPostDetails() async { guard let postId = post.postId, let postIdInt = Int(postId) else { return } + async let detailsTask = context.service.getPostDetails(for: postIdInt) + async let likesTask: PostLikes? = { + if (post.metrics.likes ?? 0) > 0 { + return try? await context.service.getPostLikes(for: postIdInt, count: 20) + } + return nil + }() + do { - let details = try await context.service.getPostDetails(for: postIdInt) + let (details, likes) = try await (detailsTask, likesTask) self.details = details + self.postLikes = likes self.isLoading = false } catch { self.error = error @@ -53,10 +85,11 @@ struct PostStatsDetailsView: View { private struct PostHeaderCard: View { let post: TopListData.Post let details: StatsPostDetails + let postLikes: PostLikes? let context: StatsContext var body: some View { - VStack(alignment: .leading, spacing: Constants.step3) { + VStack(alignment: .leading, spacing: Constants.step2) { VStack(alignment: .leading, spacing: 4) { Text(post.title) .font(.title3.weight(.semibold)) @@ -81,16 +114,22 @@ private struct PostHeaderCard: View { } } } + + // Likes strip + if let postLikes, !postLikes.users.isEmpty { + PostLikesStrip(likes: postLikes) + .padding(.top, Constants.step2) + } } + Divider() + // Metrics with visual separation HStack(spacing: Constants.step3) { MetricView(metric: .views, value: details.totalViewsCount) - if let likesCount = post.metrics.likes { MetricView(metric: .likes, value: likesCount) } - if let commentCount = details.post?.commentCount, let count = Int(commentCount) { MetricView(metric: .comments, value: count) } @@ -136,11 +175,468 @@ private struct MetricView: View { } } +private struct PostLikesStrip: View { + let likes: PostLikes + + private let avatarSize: CGFloat = 28 + private let maxVisibleAvatars = 5 + + var body: some View { + HStack { + // Overlapping avatars + HStack(spacing: -8) { + ForEach(likes.users.prefix(maxVisibleAvatars)) { user in + AvatarView(name: user.name, imageURL: user.avatarURL, size: avatarSize) + .overlay( + Circle() + .stroke(Color(UIColor.systemBackground), lineWidth: 2) + ) + } + + // Show additional count if there are more users + if likes.totalCount > maxVisibleAvatars { + Circle() + .fill(Color(UIColor.systemGray5)) + .frame(width: avatarSize, height: avatarSize) + .overlay( + Text("+\(likes.totalCount - maxVisibleAvatars)") + .font(.caption.weight(.medium)) + .foregroundColor(.secondary) + ) + .overlay( + Circle() + .stroke(Color(UIColor.systemBackground), lineWidth: 2) + ) + } + } + + Spacer() + + // Likes button + Button(action: { + // TODO: Navigate to likes detail screen + }) { + HStack(spacing: 4) { + Text(Strings.PostDetails.likesCount(likes.totalCount)) + .font(.subheadline) + .foregroundColor(.primary) + + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundColor(.secondary.opacity(0.66)) + } + } + } + } +} + +private struct PeakPerformanceCard: View { + let details: StatsPostDetails + + var body: some View { + VStack(alignment: .leading, spacing: Constants.step2) { + StatsCardTitleView(title: Strings.PostDetails.peakPerformance) + + VStack(spacing: Constants.step1) { + if let highestMonth = details.highestMonth { + HStack { + Text(Strings.PostDetails.bestMonth) + .font(.subheadline) + .foregroundColor(.secondary) + + Spacer() + + Text(StatsValueFormatter.formatNumber(highestMonth)) + .font(.subheadline.weight(.semibold)) + .monospacedDigit() + } + } + + if let highestDayAverage = details.highestDayAverage { + HStack { + Text(Strings.PostDetails.bestDayAverage) + .font(.subheadline) + .foregroundColor(.secondary) + + Spacer() + + Text(StatsValueFormatter.formatNumber(highestDayAverage)) + .font(.subheadline.weight(.semibold)) + .monospacedDigit() + } + } + + if let highestWeekAverage = details.highestWeekAverage { + HStack { + Text(Strings.PostDetails.bestWeekAverage) + .font(.subheadline) + .foregroundColor(.secondary) + + Spacer() + + Text(StatsValueFormatter.formatNumber(highestWeekAverage)) + .font(.subheadline.weight(.semibold)) + .monospacedDigit() + } + } + } + } + .padding(Constants.step2) + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +private struct WeeklyTrendsCard: View { + let weeks: [StatsWeeklyBreakdown] + let context: StatsContext + + private let cellSpacing: CGFloat = 4 + private let weekLabelWidth: CGFloat = 36 + private let dayLabels = ["M", "T", "W", "T", "F", "S", "S"] + + var body: some View { + VStack(alignment: .leading, spacing: Constants.step2) { + StatsCardTitleView(title: Strings.PostDetails.recentWeeks) + + VStack(alignment: .leading, spacing: cellSpacing) { + // Day labels header + HStack(spacing: 0) { + Color.clear + .frame(width: weekLabelWidth) + + HStack(spacing: cellSpacing) { + ForEach(dayLabels, id: \.self) { day in + Text(day) + .font(.caption2) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + } + } + } + + // Heatmap grid + VStack(spacing: cellSpacing) { + // Show last 8 weeks, 7 days per week + ForEach(Array(weeks.prefix(8).enumerated()), id: \.offset) { weekIndex, week in + HStack(spacing: 8) { + // Week label + Text(weekLabel(for: week)) + .font(.caption2) + .foregroundColor(.secondary) + .frame(width: weekLabelWidth, alignment: .trailing) + + HStack(spacing: cellSpacing) { + // Days in the week + ForEach(week.days, id: \.date) { day in + DayCell(viewsCount: day.viewsCount) + .frame(maxWidth: .infinity) + .aspectRatio(1, contentMode: .fit) + } + } + } + } + } + + // Legend + HStack(spacing: Constants.step1) { + Text(Strings.PostDetails.less) + .font(.caption2) + .foregroundColor(.secondary) + + HStack(spacing: 2) { + ForEach(0..<5) { level in + RoundedRectangle(cornerRadius: 4) + .fill(heatmapColor(for: Double(level) / 4.0)) + .frame(width: 12, height: 12) + } + } + + Text(Strings.PostDetails.more) + .font(.caption2) + .foregroundColor(.secondary) + } + .padding(.top, Constants.step1) + } + } + .padding(Constants.step2) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func weekLabel(for week: StatsWeeklyBreakdown) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "MMM d" + formatter.timeZone = context.timeZone + + guard let startDate = context.calendar.date(from: week.startDay) else { return "" } + return formatter.string(from: startDate) + } + + private func heatmapColor(for intensity: Double) -> Color { + // Use ColorStudio blue gradient + if intensity < 0.25 { + return Constants.Colors.blue.opacity(0.2) + } else if intensity < 0.5 { + return Constants.Colors.blue.opacity(0.4) + } else if intensity < 0.75 { + return Constants.Colors.blue.opacity(0.6) + } else { + return Constants.Colors.blue.opacity(0.85) + } + } +} + +private struct DayCell: View { + let viewsCount: Int + + // Define max views for normalization (can be adjusted based on data) + private let maxViews = 200 + + private var intensity: Double { + min(1.0, Double(viewsCount) / Double(maxViews)) + } + + var body: some View { + RoundedRectangle(cornerRadius: 4) + .fill(heatmapColor) + .overlay( + Text("\(viewsCount)") + .font(.caption2) + .foregroundColor(intensity > 0.6 ? .white : .primary) + .opacity(intensity > 0.3 ? 1 : 0) + ) + } + + private var heatmapColor: Color { + if viewsCount == 0 { + return Color(UIColor.systemGray6) + } + + // Use ColorStudio blue gradient + if intensity < 0.25 { + return Constants.Colors.blue.opacity(0.2) + } else if intensity < 0.5 { + return Constants.Colors.blue.opacity(0.4) + } else if intensity < 0.75 { + return Constants.Colors.blue.opacity(0.6) + } else { + return Constants.Colors.blue.opacity(0.85) + } + } +} + + +private struct YearlySummaryCard: View { + let yearlyTotals: [Int: Int] + let overallAverages: [Int: Int] + let monthlyBreakdown: [StatsPostViews] + + private let cellSpacing: CGFloat = 6 + private let monthNames = [ + ["Jan", "Feb", "Mar", "Apr", "May", "Jun"], + ["Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + ] + + var body: some View { + VStack(alignment: .leading, spacing: Constants.step2) { + StatsCardTitleView(title: Strings.PostDetails.monthlyActivity) + + // Year sections + let sortedYears = yearlyTotals.keys.sorted(by: >).prefix(3) + let maxMonthlyViews = monthlyBreakdown.map(\.viewsCount).max() ?? 5000 + + VStack(spacing: Constants.step3) { + ForEach(sortedYears, id: \.self) { year in + YearSection( + year: year, + monthlyData: getMonthlyData(for: year), + maxViews: maxMonthlyViews, + yearTotal: yearlyTotals[year] ?? 0, + previousYearTotal: yearlyTotals[year - 1] + ) + + if year != sortedYears.last { + Divider() + } + } + } + + // Legend + HStack(spacing: Constants.step2) { + HStack(spacing: Constants.step1) { + Text(Strings.PostDetails.less) + .font(.caption2) + .foregroundColor(.secondary) + + HStack(spacing: 3) { + ForEach(0..<5) { level in + RoundedRectangle(cornerRadius: 4) + .fill(heatmapColor(for: Double(level) / 4.0)) + .frame(width: 16, height: 16) + } + } + + Text(Strings.PostDetails.more) + .font(.caption2) + .foregroundColor(.secondary) + } + + Spacer() + } + .padding(.top, Constants.step1) + } + .padding(Constants.step2) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func getMonthlyData(for year: Int) -> [Int] { + var monthlyViews = Array(repeating: 0, count: 12) + + for postView in monthlyBreakdown { + if postView.date.year == year, + let month = postView.date.month, + month >= 1 && month <= 12 { + monthlyViews[month - 1] = postView.viewsCount + } + } + + return monthlyViews + } + + private func heatmapColor(for intensity: Double) -> Color { + if intensity == 0 { + return Color(.secondarySystemBackground) + } + + // Use ColorStudio blue gradient + if intensity < 0.25 { + return Constants.Colors.blue.opacity(0.2) + } else if intensity < 0.5 { + return Constants.Colors.blue.opacity(0.4) + } else if intensity < 0.75 { + return Constants.Colors.blue.opacity(0.6) + } else { + return Constants.Colors.blue.opacity(0.85) + } + } +} + +private struct YearSection: View { + let year: Int + let monthlyData: [Int] + let maxViews: Int + let yearTotal: Int + let previousYearTotal: Int? + + private let cellSpacing: CGFloat = 6 + + var body: some View { + VStack(alignment: .leading, spacing: Constants.step1) { + // Year header with total and growth + HStack(alignment: .center) { + Text(String(year)) + .font(.headline) + + Spacer() + + Text(StatsValueFormatter.formatNumber(yearTotal)) + .font(.headline) + .foregroundColor(.secondary) + .monospacedDigit() + + + if let previousTotal = previousYearTotal, previousTotal > 0 { + BadgeTrendIndicator( + trend: TrendViewModel( + currentValue: yearTotal, + previousValue: previousTotal, + metric: .views + ) + ) + } + } + + // Two-column grid + VStack(spacing: cellSpacing) { + // First row (Jan-Jun) + HStack(spacing: cellSpacing) { + ForEach(0..<6) { index in + MonthCellExpanded( + month: ["Jan", "Feb", "Mar", "Apr", "May", "Jun"][index], + viewsCount: monthlyData[index], + maxViews: maxViews + ) + } + } + + // Second row (Jul-Dec) + HStack(spacing: cellSpacing) { + ForEach(6..<12) { index in + MonthCellExpanded( + month: ["Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][index - 6], + viewsCount: monthlyData[index], + maxViews: maxViews + ) + } + } + } + } + } +} + +private struct MonthCellExpanded: View { + let month: String + let viewsCount: Int + let maxViews: Int + + private var intensity: Double { + min(1.0, Double(viewsCount) / Double(maxViews)) + } + + var body: some View { + VStack(spacing: 4) { + Text(month) + .font(.caption2) + .foregroundColor(.secondary) + + RoundedRectangle(cornerRadius: 6) + .fill(heatmapColor) + .frame(height: 44) + .overlay( + Text(StatsValueFormatter.formatNumber(viewsCount)) + .font(.subheadline.weight(.medium)) + .foregroundColor(intensity > 0.6 ? .white : .primary) + .minimumScaleFactor(0.8) + .lineLimit(1) + ) + } + .frame(maxWidth: .infinity) + } + + private var heatmapColor: Color { + if viewsCount == 0 { + return Color(UIColor.systemGray6) + } + + // Use ColorStudio blue gradient + if intensity < 0.25 { + return Constants.Colors.blue.opacity(0.2) + } else if intensity < 0.5 { + return Constants.Colors.blue.opacity(0.4) + } else if intensity < 0.75 { + return Constants.Colors.blue.opacity(0.6) + } else { + return Constants.Colors.blue.opacity(0.85) + } + } +} + + #Preview { NavigationStack { PostStatsDetailsView( post: .init( - title: "Apple's Vision Pro is a lonely computer", + title: "Matter Smart Home Protocol Still Doesn't Matter: A Year Later", postId: "12345", date: .now, pageId: nil, diff --git a/Modules/Sources/JetpackStats/Services/Data/PostLikeUser.swift b/Modules/Sources/JetpackStats/Services/Data/PostLikeUser.swift new file mode 100644 index 000000000000..0266790db55b --- /dev/null +++ b/Modules/Sources/JetpackStats/Services/Data/PostLikeUser.swift @@ -0,0 +1,23 @@ +import Foundation + +public struct PostLikeUser: Equatable, Identifiable, Sendable { + public let id: Int + public let name: String + public let avatarURL: URL? + + public init(id: Int, name: String, avatarURL: URL? = nil) { + self.id = id + self.name = name + self.avatarURL = avatarURL + } +} + +public struct PostLikes: Equatable, Sendable { + public let users: [PostLikeUser] + public let totalCount: Int + + public init(users: [PostLikeUser], totalCount: Int) { + self.users = users + self.totalCount = totalCount + } +} diff --git a/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift b/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift index 81ec6d87e1b9..3e312e35fd17 100644 --- a/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift +++ b/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift @@ -258,6 +258,54 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { return details } + + func getPostLikes(for postID: Int, count: Int) async throws -> PostLikes { + // Simulate network delay + try? await Task.sleep(for: .milliseconds(Int.random(in: 200...500))) + + // Generate mock users who liked the post + let mockUsers = [ + PostLikeUser( + id: 1, + name: "Sarah Chen", + avatarURL: Bundle.module.path(forResource: "author1", ofType: "jpg").map { URL(filePath: $0) } + ), + PostLikeUser( + id: 2, + name: "Marcus Johnson", + avatarURL: Bundle.module.path(forResource: "author2", ofType: "jpg").map { URL(filePath: $0) } + ), + PostLikeUser( + id: 3, + name: "Emily Rodriguez", + avatarURL: Bundle.module.path(forResource: "author3", ofType: "jpg").map { URL(filePath: $0) } + ), + PostLikeUser( + id: 4, + name: "Alex Thompson", + avatarURL: Bundle.module.path(forResource: "author4", ofType: "jpg").map { URL(filePath: $0) } + ), + PostLikeUser( + id: 5, + name: "Nina Patel", + avatarURL: Bundle.module.path(forResource: "author5", ofType: "jpg").map { URL(filePath: $0) } + ), + PostLikeUser( + id: 6, + name: "James Wilson", + avatarURL: Bundle.module.path(forResource: "author6", ofType: "jpg").map { URL(filePath: $0) } + ) + ] + + // Return requested number of users + let requestedCount = min(count, mockUsers.count) + let selectedUsers = Array(mockUsers.prefix(requestedCount)) + + return PostLikes( + users: selectedUsers, + totalCount: 26 // Match the mock post's like count + ) + } // MARK: - Data Aggregation diff --git a/Modules/Sources/JetpackStats/Services/StatsService.swift b/Modules/Sources/JetpackStats/Services/StatsService.swift index 5715444064e5..8335b06a8614 100644 --- a/Modules/Sources/JetpackStats/Services/StatsService.swift +++ b/Modules/Sources/JetpackStats/Services/StatsService.swift @@ -190,6 +190,40 @@ actor StatsService: StatsServiceProtocol { func getPostDetails(for postID: Int) async throws -> StatsPostDetails { try await service.getDetails(forPostID: postID) } + + func getPostLikes(for postID: Int, count: Int) async throws -> PostLikes { + // Create PostServiceRemoteREST instance + let postService = PostServiceRemoteREST( + wordPressComRestApi: api, + siteID: NSNumber(value: siteID) + ) + + // Fetch likes using the REST API + let result = try await withCheckedThrowingContinuation { continuation in + postService.getLikesForPostID( + NSNumber(value: postID), + count: NSNumber(value: count), + before: nil, + excludeUserIDs: nil, + success: { users, found in + let likeUsers = users.map { remoteLike in + PostLikeUser( + id: remoteLike.userID.intValue, + name: remoteLike.displayName ?? remoteLike.username ?? "Unknown", + avatarURL: remoteLike.avatarURL.flatMap(URL.init) + ) + } + let postLikes = PostLikes(users: likeUsers, totalCount: found.intValue) + continuation.resume(returning: postLikes) + }, + failure: { error in + continuation.resume(throwing: error ?? StatsServiceError.unknown) + } + ) + } + + return result + } // MARK: - Dates diff --git a/Modules/Sources/JetpackStats/Services/StatsServiceProtocol.swift b/Modules/Sources/JetpackStats/Services/StatsServiceProtocol.swift index 897851f78343..dbb8edfdb4ef 100644 --- a/Modules/Sources/JetpackStats/Services/StatsServiceProtocol.swift +++ b/Modules/Sources/JetpackStats/Services/StatsServiceProtocol.swift @@ -11,4 +11,5 @@ protocol StatsServiceProtocol: AnyObject, Sendable { func getTopListData(_ item: TopListItemType, metric: SiteMetric, interval: DateInterval, granularity: DateRangeGranularity) 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 -> PostLikes } diff --git a/Modules/Sources/JetpackStats/Strings.swift b/Modules/Sources/JetpackStats/Strings.swift index d5c4759e232e..e0768a4e3c66 100644 --- a/Modules/Sources/JetpackStats/Strings.swift +++ b/Modules/Sources/JetpackStats/Strings.swift @@ -124,5 +124,31 @@ enum Strings { date ) } + + // Peak Performance + static let peakPerformance = AppLocalizedString("jetpackStats.postDetails.peakPerformance", value: "Peak Performance", comment: "Title for peak performance card") + static let bestMonth = AppLocalizedString("jetpackStats.postDetails.bestMonth", value: "Best Month", comment: "Label for highest monthly views") + static let bestDayAverage = AppLocalizedString("jetpackStats.postDetails.bestDayAverage", value: "Best Day Average", comment: "Label for highest daily average views") + static let bestWeekAverage = AppLocalizedString("jetpackStats.postDetails.bestWeekAverage", value: "Best Week Average", comment: "Label for highest weekly average views") + + // Weekly Activity + static let recentWeeks = AppLocalizedString("jetpackStats.postDetails.recentWeeks", value: "Recent Weeks", comment: "Title for recent weeks activity heatmap") + static let weeklyActivity = AppLocalizedString("jetpackStats.postDetails.weeklyActivity", value: "Weekly Activity", comment: "Title for weekly activity heatmap") + static let less = AppLocalizedString("jetpackStats.postDetails.less", value: "Less", comment: "Legend label for lower activity") + static let more = AppLocalizedString("jetpackStats.postDetails.more", value: "More", comment: "Legend label for higher activity") + + // Monthly Activity + static let monthlyActivity = AppLocalizedString("jetpackStats.postDetails.monthlyActivity", value: "Monthly Activity", comment: "Title for monthly activity heatmap") + static let views = AppLocalizedString("jetpackStats.postDetails.views", value: "views", comment: "Views label (lowercase)") + static let total = AppLocalizedString("jetpackStats.postDetails.total", value: "Total", comment: "Total label") + static let avgPerDay = AppLocalizedString("jetpackStats.postDetails.avgPerDay", value: "avg/day", comment: "Average per day abbreviation") + + // Likes + static func likesCount(_ count: Int) -> String { + let format = count == 1 + ? AppLocalizedString("jetpackStats.postDetails.like", value: "%1$d like", comment: "Singular like count. %1$d is the number.") + : AppLocalizedString("jetpackStats.postDetails.likes", value: "%1$d likes", comment: "Plural likes count. %1$d is the number.") + return String.localizedStringWithFormat(format, count) + } } } From 5bb0a34da89f6905b3a812a0a9a1a6990f16a0a7 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Wed, 23 Jul 2025 10:07:49 -0400 Subject: [PATCH 023/349] Add Views Over Time chart --- Modules/Package.resolved | 4 +- Modules/Package.swift | 2 +- .../Screens/PostStatsDetailsView.swift | 166 +++++++++++++++++- Modules/Sources/JetpackStats/Strings.swift | 6 + 4 files changed, 174 insertions(+), 4 deletions(-) diff --git a/Modules/Package.resolved b/Modules/Package.resolved index 2d6028c5661a..3f7c942a38c9 100644 --- a/Modules/Package.resolved +++ b/Modules/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "e47922a7de43ee329974363b3d3a014043606f9384f9d8db505b1b98828827c5", + "originHash" : "f282a9bbd2eb46463022d668957f80330ad95f693c8bec3425a741e12e4b2f39", "pins" : [ { "identity" : "alamofire", @@ -381,7 +381,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/wordpress-mobile/WordPressKit-iOS", "state" : { - "revision" : "32f941002c04f431f4c8de072bdc898401493500" + "revision" : "c31ddf14716f5ce5d810a21d1cacb5c2da8709d5" } }, { diff --git a/Modules/Package.swift b/Modules/Package.swift index b39ea4af1b57..5b169e06a6c7 100644 --- a/Modules/Package.swift +++ b/Modules/Package.swift @@ -50,7 +50,7 @@ let package = Package( .package(url: "https://github.com/wordpress-mobile/NSURL-IDN", revision: "b34794c9a3f32312e1593d4a3d120572afa0d010"), .package( url: "https://github.com/wordpress-mobile/WordPressKit-iOS", - revision: "32f941002c04f431f4c8de072bdc898401493500" // see wpios-edition branch + revision: "c31ddf14716f5ce5d810a21d1cacb5c2da8709d5" // see wpios-edition branch ), .package(url: "https://github.com/zendesk/support_sdk_ios", from: "8.0.3"), // We can't use wordpress-rs branches nor commits here. Only tags work. diff --git a/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift b/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift index 1bc970a4668f..7b9b2dccaada 100644 --- a/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift +++ b/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift @@ -1,6 +1,7 @@ import SwiftUI import UIKit import WordPressKit +import Charts struct PostStatsDetailsView: View { let post: TopListData.Post @@ -10,6 +11,15 @@ struct PostStatsDetailsView: View { @State private var postLikes: PostLikes? @State private var isLoading = true @State private var error: Error? + @State private var chartDateRange: StatsDateRange + + init(post: TopListData.Post) { + self.post = post + // Initialize with last 14 days + let calendar = Calendar.current + let dateRange = calendar.makeDateRange(for: .last7Days) + self._chartDateRange = State(initialValue: dateRange) + } var body: some View { ScrollView { @@ -18,6 +28,10 @@ struct PostStatsDetailsView: View { PostHeaderCard(post: post, details: details, postLikes: postLikes, context: context) .cardStyle() + // Views Over Time Chart + PostViewsChartCard(details: details, dateRange: $chartDateRange) + .cardStyle() + // Peak Performance Card if details.highestMonth != nil || details.highestDayAverage != nil || details.highestWeekAverage != nil { PeakPerformanceCard(details: details) @@ -425,7 +439,6 @@ private struct DayCell: View { } } - private struct YearlySummaryCard: View { let yearlyTotals: [Int: Int] let overallAverages: [Int: Int] @@ -631,6 +644,157 @@ private struct MonthCellExpanded: View { } } +// MARK: - Chart Components + +private enum ChartType: String, CaseIterable { + case line + case columns + + var localizedTitle: String { + switch self { + case .line: Strings.Chart.lineChart + case .columns: Strings.Chart.barChart + } + } + + var systemImage: String { + switch self { + case .line: "chart.line.uptrend.xyaxis" + case .columns: "chart.bar" + } + } +} + +private struct PostViewsChartCard: View { + let details: StatsPostDetails + @Binding var dateRange: StatsDateRange + @State private var selectedChartType: ChartType = .line + @State private var isShowingDatePicker = false + @Environment(\.context) private var context + + var body: some View { + VStack(spacing: 0) { + VStack(spacing: Constants.step2) { + // Header + HStack { + StatsCardTitleView(title: Strings.PostDetails.viewsOverTime) + Spacer() + } + + // Period selector + HStack { + Button(action: { isShowingDatePicker = true }) { + HStack(spacing: 4) { + Text(context.dateRangeFormatter.string(from: dateRange)) + .font(.subheadline) + .foregroundColor(.secondary) + Image(systemName: "chevron.down") + .font(.caption2) + .foregroundColor(.secondary) + } + } + Spacer() + } + + // Chart content + chartContent + .frame(height: 180) + } + .padding(Constants.step2) + } + .overlay(alignment: .topTrailing) { + moreMenu + } + .sheet(isPresented: $isShowingDatePicker) { + CustomDateRangePicker(dateRange: $dateRange) + } + } + + @ViewBuilder + private var chartContent: some View { + switch selectedChartType { + case .line: + LineChartView(data: chartData) + case .columns: + BarChartView(data: chartData) + } + } + + private var chartData: ChartData { + // Get all available data points from different sources + let allDataPoints = getAllAvailableDataPoints() + + // Filter data points within the selected date range + let filteredDataPoints = allDataPoints.filter { dataPoint in + dateRange.dateInterval.contains(dataPoint.date) + } + + // Determine appropriate granularity based on date range + let granularity = dateRange.dateInterval.preferredGranularity + + // Aggregate data based on granularity + let aggregator = StatsDataAggregator(calendar: context.calendar) + let aggregatedData = aggregator.aggregate(filteredDataPoints, granularity: granularity) + let normalizedData = aggregator.normalizeForMetric(aggregatedData, metric: .views) + + // Generate complete date sequence for the range + let dateSequence = aggregator.generateDateSequence( + dateInterval: dateRange.dateInterval, + by: granularity.component + ) + + // Create data points for chart + let dataPoints = dateSequence.map { date in + let aggregationDate = aggregator.makeAggegationDate(for: date, granularity: granularity) + return DataPoint(date: date, value: normalizedData[aggregationDate ?? date] ?? 0) + } + + let total = dataPoints.reduce(0) { $0 + $1.value } + + // For post details, we don't have previous period data + return ChartData( + metric: .views, + granularity: granularity, + currentTotal: total, + currentData: dataPoints, + previousTotal: 0, + previousData: [], + mappedPreviousData: [] + ) + } + + private func getAllAvailableDataPoints() -> [DataPoint] { + // Use the data field which contains all available daily data + return details.data.compactMap { postView in + guard let date = context.calendar.date(from: postView.date) else { return nil } + return DataPoint(date: date, value: postView.viewsCount) + } + } + + private var moreMenu: some View { + Menu { + Section { + ControlGroup { + ForEach(ChartType.allCases, id: \.self) { type in + Button { + selectedChartType = type + } label: { + Label(type.localizedTitle, systemImage: type.systemImage) + } + } + } + } + } label: { + Image(systemName: "ellipsis") + .font(.body) + .foregroundColor(.secondary) + .frame(width: 50, height: 50) + } + .tint(Color.primary) + } +} + + #Preview { NavigationStack { diff --git a/Modules/Sources/JetpackStats/Strings.swift b/Modules/Sources/JetpackStats/Strings.swift index e0768a4e3c66..25cc02b5d102 100644 --- a/Modules/Sources/JetpackStats/Strings.swift +++ b/Modules/Sources/JetpackStats/Strings.swift @@ -150,5 +150,11 @@ enum Strings { : AppLocalizedString("jetpackStats.postDetails.likes", value: "%1$d likes", comment: "Plural likes count. %1$d is the number.") return String.localizedStringWithFormat(format, count) } + + // Chart + static let viewsOverTime = AppLocalizedString("jetpackStats.postDetails.viewsOverTime", value: "Views Over Time", comment: "Title for post views chart") + static let last14Days = AppLocalizedString("jetpackStats.postDetails.last14Days", value: "Last 14 Days", comment: "Chart period option") + static let monthly = AppLocalizedString("jetpackStats.postDetails.monthly", value: "Monthly", comment: "Chart period option") + static let weekly = AppLocalizedString("jetpackStats.postDetails.weekly", value: "Weekly", comment: "Chart period option") } } From 1e850cf470ee2708259ca276631e2bb4e097ff5b Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Wed, 23 Jul 2025 11:53:15 -0400 Subject: [PATCH 024/349] Extract StandaloneChartCard --- .../Cards/StandaloneChartCard.swift | 316 ++++++++++++++++++ .../Resources/Mocks/Misc/post-details.json | 302 ++++++++++++++++- .../Screens/PostStatsDetailsView.swift | 180 ++-------- Modules/Sources/JetpackStats/Strings.swift | 6 - 4 files changed, 640 insertions(+), 164 deletions(-) create mode 100644 Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift diff --git a/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift b/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift new file mode 100644 index 000000000000..7a8140dbbfcf --- /dev/null +++ b/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift @@ -0,0 +1,316 @@ +import SwiftUI +import Charts + +/// A reusable chart card component that displays metric data over time with date range controls. +/// +/// This component provides: +/// - Line and bar chart visualization options +/// - Date range selection and navigation +/// - Comparison with previous period +/// - Automatic data aggregation based on selected granularity +struct StandaloneChartCard: View { + /// The data points to display in the chart + let dataPoints: [DataPoint] + + /// The metric type being displayed (e.g., views, likes, comments) + let metric: SiteMetric + + @State private var dateRange: StatsDateRange + @State private var selectedChartType: ChartType = .line + @State private var isShowingDatePicker = false + + @ScaledMetric private var chartHeight = 160 + + @Environment(\.context) private var context + + /// Creates a new standalone chart card. + /// - Parameters: + /// - dataPoints: The array of data points to display + /// - metric: The metric type for proper formatting and colors + /// - initialDateRange: The initial date range to display + init(dataPoints: [DataPoint], metric: SiteMetric, initialDateRange: StatsDateRange) { + self.dataPoints = dataPoints + self.metric = metric + self._dateRange = State(initialValue: initialDateRange) + } + + var body: some View { + VStack(spacing: Constants.step1) { + VStack(alignment: .leading, spacing: 8) { + HStack { + StatsCardTitleView(title: metric.localizedTitle) + Spacer() + } + + // Show legend for current and comparison periods + ChartLegendView( + metric: metric, + currentPeriod: dateRange.dateInterval, + previousPeriod: dateRange.effectiveComparisonInterval + ) + + ChartValuesSummaryView(trend: trend, style: .compact) + .padding(.top, 8) + } + + // Chart content + chartContent + .frame(height: chartHeight) + + // Date range controls + dateRangeControls + } + .padding(Constants.step2) + .overlay(alignment: .topTrailing) { + moreMenu + } + .sheet(isPresented: $isShowingDatePicker) { + CustomDateRangePicker(dateRange: $dateRange) + } + } + + // MARK: - Chart Content + + @ViewBuilder + private var chartContent: some View { + switch selectedChartType { + case .line: + LineChartView(data: chartData) + case .columns: + BarChartView(data: chartData) + } + } + + private var trend: TrendViewModel { + TrendViewModel( + currentValue: chartData.currentTotal, + previousValue: chartData.previousTotal, + metric: metric + ) + } + + private var chartData: ChartData { + // Filter data points within the selected date range + let filteredDataPoints = dataPoints.filter { dataPoint in + dateRange.dateInterval.contains(dataPoint.date) + } + + // Determine appropriate granularity based on date range + let granularity = dateRange.dateInterval.preferredGranularity + + // Aggregate data based on granularity + let aggregator = StatsDataAggregator(calendar: context.calendar) + let aggregatedData = aggregator.aggregate(filteredDataPoints, granularity: granularity) + let normalizedData = aggregator.normalizeForMetric(aggregatedData, metric: metric) + + // Generate complete date sequence for the range + let dateSequence = aggregator.generateDateSequence( + dateInterval: dateRange.dateInterval, + by: granularity.component + ) + + // Create data points for chart + let currentDataPoints = dateSequence.map { date in + let aggregationDate = aggregator.makeAggegationDate(for: date, granularity: granularity) + return DataPoint(date: date, value: normalizedData[aggregationDate ?? date] ?? 0) + } + + let currentTotal = currentDataPoints.reduce(0) { $0 + $1.value } + + // Calculate previous period data for comparison + let previousDateRange = StatsDateRange( + interval: dateRange.effectiveComparisonInterval, + component: dateRange.component, + comparison: dateRange.comparison, + calendar: context.calendar + ) + + // Get data points for previous period + let previousFilteredDataPoints = dataPoints.filter { dataPoint in + previousDateRange.dateInterval.contains(dataPoint.date) + } + + // Aggregate previous period data + let previousAggregated = aggregator.aggregate(previousFilteredDataPoints, granularity: granularity) + let previousNormalized = aggregator.normalizeForMetric(previousAggregated, metric: metric) + + // Generate date sequence for previous period + let previousDateSequence = aggregator.generateDateSequence( + dateInterval: previousDateRange.dateInterval, + by: granularity.component + ) + + // Create previous data points + let previousDataPoints = previousDateSequence.map { date in + let aggregationDate = aggregator.makeAggegationDate(for: date, granularity: granularity) + return DataPoint(date: date, value: previousNormalized[aggregationDate ?? date] ?? 0) + } + + let previousTotal = previousDataPoints.reduce(0) { $0 + $1.value } + + // Map previous data points to current period dates for overlay + let mappedPreviousData = DataPoint.mapDataPoints( + previousDataPoints, + from: previousDateRange.dateInterval, + to: dateRange.dateInterval, + component: dateRange.component, + calendar: context.calendar + ) + + return ChartData( + metric: metric, + granularity: granularity, + currentTotal: currentTotal, + currentData: currentDataPoints, + previousTotal: previousTotal, + previousData: previousDataPoints, + mappedPreviousData: mappedPreviousData + ) + } + + // MARK: - Controls + + private var moreMenu: some View { + Menu { + Section { + ControlGroup { + ForEach(ChartType.allCases, id: \.self) { type in + Button { + selectedChartType = type + } label: { + Label(type.localizedTitle, systemImage: type.systemImage) + } + } + } + } + } label: { + Image(systemName: "ellipsis") + .font(.body) + .foregroundColor(.secondary) + .frame(width: 50, height: 50) + } + .tint(Color.primary) + } + + private var dateRangeControls: some View { + HStack(spacing: Constants.step1) { + // Date range menu button + Menu { + StatsDateRangePickerMenu(selection: $dateRange, isShowingCustomRangePicker: $isShowingDatePicker) + } label: { + HStack(spacing: 6) { + Image(systemName: "calendar") + .font(.subheadline) + Text(context.formatters.dateRange.string(from: dateRange.dateInterval)) + .font(.subheadline.weight(.medium)) + } + .foregroundColor(.primary) + .padding(.horizontal, Constants.step1) + .padding(.vertical, 8) + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .tint(Color.primary) + + Spacer() + + // Navigation controls + HStack(spacing: 4) { + // Previous button + Button { + withAnimation(.spring(response: 0.3)) { + dateRange = dateRange.navigate(.backward) + } + } label: { + Image(systemName: "chevron.left") + .font(.subheadline.weight(.medium)) + .foregroundColor(dateRange.canNavigate(in: .backward) ? .primary : Color(.quaternaryLabel)) + .frame(width: 36, height: 36) + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .disabled(!dateRange.canNavigate(in: .backward)) + + // Next button + Button { + withAnimation(.spring(response: 0.3)) { + dateRange = dateRange.navigate(.forward) + } + } label: { + Image(systemName: "chevron.right") + .font(.subheadline.weight(.medium)) + .foregroundColor(dateRange.canNavigate(in: .forward) ? .primary : Color(.quaternaryLabel)) + .frame(width: 36, height: 36) + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .disabled(!dateRange.canNavigate(in: .forward)) + } + } + } +} + +// MARK: - Chart Type + +private enum ChartType: String, CaseIterable { + case line + case columns + + var localizedTitle: String { + switch self { + case .line: Strings.Chart.lineChart + case .columns: Strings.Chart.barChart + } + } + + var systemImage: String { + switch self { + case .line: "chart.line.uptrend.xyaxis" + case .columns: "chart.bar" + } + } +} + +// MARK: - Preview + +#Preview("Views Chart") { + let calendar = Calendar.current + let dateRange = calendar.makeDateRange(for: .last7Days) + + return StandaloneChartCard( + dataPoints: generateMockDataPoints(days: 365), + metric: .views, + initialDateRange: dateRange + ) + .cardStyle() + .padding() + .background(Color(.systemGroupedBackground)) + .environment(\.context, StatsContext.demo) +} + +#Preview("Likes Chart") { + let calendar = Calendar.current + let dateRange = calendar.makeDateRange(for: .last30Days) + + return StandaloneChartCard( + dataPoints: generateMockDataPoints(days: 365, valueRange: 10...50), + metric: .likes, + initialDateRange: dateRange + ) + .cardStyle() + .padding() + .background(Color(.systemGroupedBackground)) + .environment(\.context, StatsContext.demo) +} + +// Helper function to generate mock data +private func generateMockDataPoints(days: Int, valueRange: ClosedRange = 50...200) -> [DataPoint] { + let calendar = Calendar.current + let today = Date() + + return (0.. [DataPoint] { + // Convert StatsPostViews to DataPoints using the site timezone + return data.compactMap { postView in + // Convert DateComponents to Date using site timezone (similar to how StatsService does it) + var calendar = context.calendar + calendar.timeZone = context.timeZone + + guard let date = calendar.date(from: postView.date) else { return nil } + return DataPoint(date: date, value: postView.viewsCount) + } + } } private struct PostHeaderCard: View { @@ -644,155 +659,6 @@ private struct MonthCellExpanded: View { } } -// MARK: - Chart Components - -private enum ChartType: String, CaseIterable { - case line - case columns - - var localizedTitle: String { - switch self { - case .line: Strings.Chart.lineChart - case .columns: Strings.Chart.barChart - } - } - - var systemImage: String { - switch self { - case .line: "chart.line.uptrend.xyaxis" - case .columns: "chart.bar" - } - } -} - -private struct PostViewsChartCard: View { - let details: StatsPostDetails - @Binding var dateRange: StatsDateRange - @State private var selectedChartType: ChartType = .line - @State private var isShowingDatePicker = false - @Environment(\.context) private var context - - var body: some View { - VStack(spacing: 0) { - VStack(spacing: Constants.step2) { - // Header - HStack { - StatsCardTitleView(title: Strings.PostDetails.viewsOverTime) - Spacer() - } - - // Period selector - HStack { - Button(action: { isShowingDatePicker = true }) { - HStack(spacing: 4) { - Text(context.dateRangeFormatter.string(from: dateRange)) - .font(.subheadline) - .foregroundColor(.secondary) - Image(systemName: "chevron.down") - .font(.caption2) - .foregroundColor(.secondary) - } - } - Spacer() - } - - // Chart content - chartContent - .frame(height: 180) - } - .padding(Constants.step2) - } - .overlay(alignment: .topTrailing) { - moreMenu - } - .sheet(isPresented: $isShowingDatePicker) { - CustomDateRangePicker(dateRange: $dateRange) - } - } - - @ViewBuilder - private var chartContent: some View { - switch selectedChartType { - case .line: - LineChartView(data: chartData) - case .columns: - BarChartView(data: chartData) - } - } - - private var chartData: ChartData { - // Get all available data points from different sources - let allDataPoints = getAllAvailableDataPoints() - - // Filter data points within the selected date range - let filteredDataPoints = allDataPoints.filter { dataPoint in - dateRange.dateInterval.contains(dataPoint.date) - } - - // Determine appropriate granularity based on date range - let granularity = dateRange.dateInterval.preferredGranularity - - // Aggregate data based on granularity - let aggregator = StatsDataAggregator(calendar: context.calendar) - let aggregatedData = aggregator.aggregate(filteredDataPoints, granularity: granularity) - let normalizedData = aggregator.normalizeForMetric(aggregatedData, metric: .views) - - // Generate complete date sequence for the range - let dateSequence = aggregator.generateDateSequence( - dateInterval: dateRange.dateInterval, - by: granularity.component - ) - - // Create data points for chart - let dataPoints = dateSequence.map { date in - let aggregationDate = aggregator.makeAggegationDate(for: date, granularity: granularity) - return DataPoint(date: date, value: normalizedData[aggregationDate ?? date] ?? 0) - } - - let total = dataPoints.reduce(0) { $0 + $1.value } - - // For post details, we don't have previous period data - return ChartData( - metric: .views, - granularity: granularity, - currentTotal: total, - currentData: dataPoints, - previousTotal: 0, - previousData: [], - mappedPreviousData: [] - ) - } - - private func getAllAvailableDataPoints() -> [DataPoint] { - // Use the data field which contains all available daily data - return details.data.compactMap { postView in - guard let date = context.calendar.date(from: postView.date) else { return nil } - return DataPoint(date: date, value: postView.viewsCount) - } - } - - private var moreMenu: some View { - Menu { - Section { - ControlGroup { - ForEach(ChartType.allCases, id: \.self) { type in - Button { - selectedChartType = type - } label: { - Label(type.localizedTitle, systemImage: type.systemImage) - } - } - } - } - } label: { - Image(systemName: "ellipsis") - .font(.body) - .foregroundColor(.secondary) - .frame(width: 50, height: 50) - } - .tint(Color.primary) - } -} diff --git a/Modules/Sources/JetpackStats/Strings.swift b/Modules/Sources/JetpackStats/Strings.swift index 25cc02b5d102..e0768a4e3c66 100644 --- a/Modules/Sources/JetpackStats/Strings.swift +++ b/Modules/Sources/JetpackStats/Strings.swift @@ -150,11 +150,5 @@ enum Strings { : AppLocalizedString("jetpackStats.postDetails.likes", value: "%1$d likes", comment: "Plural likes count. %1$d is the number.") return String.localizedStringWithFormat(format, count) } - - // Chart - static let viewsOverTime = AppLocalizedString("jetpackStats.postDetails.viewsOverTime", value: "Views Over Time", comment: "Title for post views chart") - static let last14Days = AppLocalizedString("jetpackStats.postDetails.last14Days", value: "Last 14 Days", comment: "Chart period option") - static let monthly = AppLocalizedString("jetpackStats.postDetails.monthly", value: "Monthly", comment: "Chart period option") - static let weekly = AppLocalizedString("jetpackStats.postDetails.weekly", value: "Weekly", comment: "Chart period option") } } From 31a6efe0395ee1005d1829207b16f4e285d0e20a Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Wed, 23 Jul 2025 11:54:06 -0400 Subject: [PATCH 025/349] Reuse ChartType --- .../JetpackStats/Cards/ChartCard.swift | 2 +- .../Cards/StandaloneChartCard.swift | 21 ------------------- 2 files changed, 1 insertion(+), 22 deletions(-) diff --git a/Modules/Sources/JetpackStats/Cards/ChartCard.swift b/Modules/Sources/JetpackStats/Cards/ChartCard.swift index 3850f42a4745..93a09abdb833 100644 --- a/Modules/Sources/JetpackStats/Cards/ChartCard.swift +++ b/Modules/Sources/JetpackStats/Cards/ChartCard.swift @@ -177,7 +177,7 @@ struct ChartCard: View { } } -private enum ChartType: String, CaseIterable { +enum ChartType: String, CaseIterable { case line case columns diff --git a/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift b/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift index 7a8140dbbfcf..8833cf98a90e 100644 --- a/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift +++ b/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift @@ -250,27 +250,6 @@ struct StandaloneChartCard: View { } } -// MARK: - Chart Type - -private enum ChartType: String, CaseIterable { - case line - case columns - - var localizedTitle: String { - switch self { - case .line: Strings.Chart.lineChart - case .columns: Strings.Chart.barChart - } - } - - var systemImage: String { - switch self { - case .line: "chart.line.uptrend.xyaxis" - case .columns: "chart.bar" - } - } -} - // MARK: - Preview #Preview("Views Chart") { From b3f241506e04d30e0c1ae4c5a24334f8f0397a65 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Wed, 23 Jul 2025 11:59:58 -0400 Subject: [PATCH 026/349] Perform processing in the background --- .../Cards/StandaloneChartCard.swift | 40 +++++++++++++++---- .../JetpackStats/Charts/ChartData.swift | 2 +- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift b/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift index 8833cf98a90e..583b425f4d8d 100644 --- a/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift +++ b/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift @@ -18,6 +18,8 @@ struct StandaloneChartCard: View { @State private var dateRange: StatsDateRange @State private var selectedChartType: ChartType = .line @State private var isShowingDatePicker = false + @State private var cachedChartData: ChartData? + @State private var isComputingData = false @ScaledMetric private var chartHeight = 160 @@ -54,8 +56,13 @@ struct StandaloneChartCard: View { } // Chart content - chartContent - .frame(height: chartHeight) + if let chartData = cachedChartData { + chartContent(chartData: chartData) + .frame(height: chartHeight) + } else if isComputingData { + ProgressView() + .frame(height: chartHeight) + } // Date range controls dateRangeControls @@ -67,12 +74,15 @@ struct StandaloneChartCard: View { .sheet(isPresented: $isShowingDatePicker) { CustomDateRangePicker(dateRange: $dateRange) } + .task(id: dateRange) { + await computeChartData() + } } // MARK: - Chart Content @ViewBuilder - private var chartContent: some View { + private func chartContent(chartData: ChartData) -> some View { switch selectedChartType { case .line: LineChartView(data: chartData) @@ -82,14 +92,30 @@ struct StandaloneChartCard: View { } private var trend: TrendViewModel { - TrendViewModel( + guard let chartData = cachedChartData else { + return TrendViewModel(currentValue: 0, previousValue: 0, metric: metric) + } + return TrendViewModel( currentValue: chartData.currentTotal, previousValue: chartData.previousTotal, metric: metric ) } - private var chartData: ChartData { + @MainActor + private func computeChartData() async { + isComputingData = true + defer { isComputingData = false } + + // Perform the computation asynchronously + let data = await Task.detached { [dataPoints, dateRange, metric, context] in + return self.generateChartData(dataPoints: dataPoints, dateRange: dateRange, metric: metric, context: context) + }.value + + cachedChartData = data + } + + private func generateChartData(dataPoints: [DataPoint], dateRange: StatsDateRange, metric: SiteMetric, context: StatsContext) -> ChartData { // Filter data points within the selected date range let filteredDataPoints = dataPoints.filter { dataPoint in dateRange.dateInterval.contains(dataPoint.date) @@ -222,7 +248,7 @@ struct StandaloneChartCard: View { dateRange = dateRange.navigate(.backward) } } label: { - Image(systemName: "chevron.left") + Image(systemName: "chevron.backward") .font(.subheadline.weight(.medium)) .foregroundColor(dateRange.canNavigate(in: .backward) ? .primary : Color(.quaternaryLabel)) .frame(width: 36, height: 36) @@ -237,7 +263,7 @@ struct StandaloneChartCard: View { dateRange = dateRange.navigate(.forward) } } label: { - Image(systemName: "chevron.right") + Image(systemName: "chevron.forward") .font(.subheadline.weight(.medium)) .foregroundColor(dateRange.canNavigate(in: .forward) ? .primary : Color(.quaternaryLabel)) .frame(width: 36, height: 36) diff --git a/Modules/Sources/JetpackStats/Charts/ChartData.swift b/Modules/Sources/JetpackStats/Charts/ChartData.swift index 3068cc2865c2..d981dec9e952 100644 --- a/Modules/Sources/JetpackStats/Charts/ChartData.swift +++ b/Modules/Sources/JetpackStats/Charts/ChartData.swift @@ -1,6 +1,6 @@ import SwiftUI -final class ChartData { +final class ChartData: Sendable { let metric: SiteMetric let granularity: DateRangeGranularity let currentTotal: Int From 921cf77fc7cbc4bb36c628940b2965e532b46f0b Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Wed, 23 Jul 2025 12:07:10 -0400 Subject: [PATCH 027/349] Extract reusable processPeriod --- .../Cards/StandaloneChartCard.swift | 143 +++++-------- .../Services/Mocks/StatsDataAggregator.swift | 41 ++++ .../MockStatsServiceTests.swift | 2 +- .../StatsDataAggregationTests.swift | 202 ++++++++++++++++++ 4 files changed, 302 insertions(+), 86 deletions(-) diff --git a/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift b/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift index 583b425f4d8d..c115b970a822 100644 --- a/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift +++ b/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift @@ -106,94 +106,15 @@ struct StandaloneChartCard: View { private func computeChartData() async { isComputingData = true defer { isComputingData = false } - - // Perform the computation asynchronously - let data = await Task.detached { [dataPoints, dateRange, metric, context] in - return self.generateChartData(dataPoints: dataPoints, dateRange: dateRange, metric: metric, context: context) - }.value - - cachedChartData = data - } - - private func generateChartData(dataPoints: [DataPoint], dateRange: StatsDateRange, metric: SiteMetric, context: StatsContext) -> ChartData { - // Filter data points within the selected date range - let filteredDataPoints = dataPoints.filter { dataPoint in - dateRange.dateInterval.contains(dataPoint.date) - } - - // Determine appropriate granularity based on date range - let granularity = dateRange.dateInterval.preferredGranularity - - // Aggregate data based on granularity - let aggregator = StatsDataAggregator(calendar: context.calendar) - let aggregatedData = aggregator.aggregate(filteredDataPoints, granularity: granularity) - let normalizedData = aggregator.normalizeForMetric(aggregatedData, metric: metric) - - // Generate complete date sequence for the range - let dateSequence = aggregator.generateDateSequence( - dateInterval: dateRange.dateInterval, - by: granularity.component - ) - - // Create data points for chart - let currentDataPoints = dateSequence.map { date in - let aggregationDate = aggregator.makeAggegationDate(for: date, granularity: granularity) - return DataPoint(date: date, value: normalizedData[aggregationDate ?? date] ?? 0) - } - - let currentTotal = currentDataPoints.reduce(0) { $0 + $1.value } - - // Calculate previous period data for comparison - let previousDateRange = StatsDateRange( - interval: dateRange.effectiveComparisonInterval, - component: dateRange.component, - comparison: dateRange.comparison, - calendar: context.calendar - ) - - // Get data points for previous period - let previousFilteredDataPoints = dataPoints.filter { dataPoint in - previousDateRange.dateInterval.contains(dataPoint.date) - } - - // Aggregate previous period data - let previousAggregated = aggregator.aggregate(previousFilteredDataPoints, granularity: granularity) - let previousNormalized = aggregator.normalizeForMetric(previousAggregated, metric: metric) - - // Generate date sequence for previous period - let previousDateSequence = aggregator.generateDateSequence( - dateInterval: previousDateRange.dateInterval, - by: granularity.component - ) - - // Create previous data points - let previousDataPoints = previousDateSequence.map { date in - let aggregationDate = aggregator.makeAggegationDate(for: date, granularity: granularity) - return DataPoint(date: date, value: previousNormalized[aggregationDate ?? date] ?? 0) - } - - let previousTotal = previousDataPoints.reduce(0) { $0 + $1.value } - - // Map previous data points to current period dates for overlay - let mappedPreviousData = DataPoint.mapDataPoints( - previousDataPoints, - from: previousDateRange.dateInterval, - to: dateRange.dateInterval, - component: dateRange.component, - calendar: context.calendar - ) - - return ChartData( + + cachedChartData = await generateChartData( + dataPoints: dataPoints, + dateRange: dateRange, metric: metric, - granularity: granularity, - currentTotal: currentTotal, - currentData: currentDataPoints, - previousTotal: previousTotal, - previousData: previousDataPoints, - mappedPreviousData: mappedPreviousData + context: context ) } - + // MARK: - Controls private var moreMenu: some View { @@ -276,6 +197,58 @@ struct StandaloneChartCard: View { } } +private func generateChartData(dataPoints: [DataPoint], dateRange: StatsDateRange, metric: SiteMetric, context: StatsContext) async -> ChartData { + let granularity = dateRange.dateInterval.preferredGranularity + let aggregator = StatsDataAggregator(calendar: context.calendar) + + // Filter data points for current period + let currentDataPoints = dataPoints.filter { dataPoint in + dateRange.dateInterval.contains(dataPoint.date) + } + + // Process current period + let currentPeriod = aggregator.processPeriod( + dataPoints: currentDataPoints, + dateInterval: dateRange.dateInterval, + granularity: granularity, + metric: metric + ) + + // Create previous period using calendar extension + let previousDateInterval = dateRange.effectiveComparisonInterval + + // Filter data points for previous period + let previousDataPoints = dataPoints.filter { dataPoint in + previousDateInterval.contains(dataPoint.date) + } + + let previousPeriod = aggregator.processPeriod( + dataPoints: previousDataPoints, + dateInterval: previousDateInterval, + granularity: granularity, + metric: metric + ) + + // Map previous data points to current period dates for overlay + let mappedPreviousData = DataPoint.mapDataPoints( + previousPeriod.dataPoints, + from: previousDateInterval, + to: dateRange.dateInterval, + component: dateRange.component, + calendar: context.calendar + ) + + return ChartData( + metric: metric, + granularity: granularity, + currentTotal: currentPeriod.total, + currentData: currentPeriod.dataPoints, + previousTotal: previousPeriod.total, + previousData: previousPeriod.dataPoints, + mappedPreviousData: mappedPreviousData + ) +} + // MARK: - Preview #Preview("Views Chart") { diff --git a/Modules/Sources/JetpackStats/Services/Mocks/StatsDataAggregator.swift b/Modules/Sources/JetpackStats/Services/Mocks/StatsDataAggregator.swift index c803bccbe953..df680ce9f757 100644 --- a/Modules/Sources/JetpackStats/Services/Mocks/StatsDataAggregator.swift +++ b/Modules/Sources/JetpackStats/Services/Mocks/StatsDataAggregator.swift @@ -111,4 +111,45 @@ struct StatsDataAggregator { } return dates } + + /// Processes a period of data by aggregating and normalizing data points. + /// - Parameters: + /// - dataPoints: Data points already filtered for the period + /// - dateInterval: The date interval to process + /// - granularity: The aggregation granularity + /// - metric: The metric type for normalization + /// - Returns: Processed period data with aggregated data points and total + func processPeriod( + dataPoints: [DataPoint], + dateInterval: DateInterval, + granularity: DateRangeGranularity, + metric: SiteMetric + ) -> PeriodData { + // Aggregate data + let aggregatedData = aggregate(dataPoints, granularity: granularity) + let normalizedData = normalizeForMetric(aggregatedData, metric: metric) + + // Generate complete date sequence for the range + let dateSequence = generateDateSequence( + dateInterval: dateInterval, + by: granularity.component + ) + + // Create data points for each date in the sequence + let periodDataPoints = dateSequence.map { date in + let aggregationDate = makeAggegationDate(for: date, granularity: granularity) + return DataPoint(date: date, value: normalizedData[aggregationDate ?? date] ?? 0) + } + + // Calculate total using DataPoint's getTotalValue method + let total = DataPoint.getTotalValue(for: periodDataPoints, metric: metric) ?? 0 + + return PeriodData(dataPoints: periodDataPoints, total: total) + } +} + +/// Represents processed data for a specific period +struct PeriodData { + let dataPoints: [DataPoint] + let total: Int } diff --git a/Modules/Tests/JetpackStatsTests/MockStatsServiceTests.swift b/Modules/Tests/JetpackStatsTests/MockStatsServiceTests.swift index de813298a92f..b94cc8e720da 100644 --- a/Modules/Tests/JetpackStatsTests/MockStatsServiceTests.swift +++ b/Modules/Tests/JetpackStatsTests/MockStatsServiceTests.swift @@ -14,7 +14,7 @@ struct MockStatsServiceTests { // WHEN let response = try await service.getTopListData( - .posts, + .postsAndPages, metric: .views, interval: dateInterval, granularity: dateInterval.preferredGranularity diff --git a/Modules/Tests/JetpackStatsTests/StatsDataAggregationTests.swift b/Modules/Tests/JetpackStatsTests/StatsDataAggregationTests.swift index 29fb3b4ae2a5..3637d7cd5995 100644 --- a/Modules/Tests/JetpackStatsTests/StatsDataAggregationTests.swift +++ b/Modules/Tests/JetpackStatsTests/StatsDataAggregationTests.swift @@ -247,4 +247,206 @@ struct StatsDataAggregationTests { #expect(normalized[Date("2025-01-15T00:00:00Z")] == 200) // 600/3 #expect(normalized[Date("2025-01-16T00:00:00Z")] == 300) // 900/3 } + + // MARK: - Process Period Tests + + @Test + func processPeriodDailyGranularity() { + let aggregator = StatsDataAggregator(calendar: calendar) + + // Create test data spanning multiple days + let allDataPoints = [ + DataPoint(date: Date("2025-01-14T10:00:00Z"), value: 50), // Outside range + DataPoint(date: Date("2025-01-15T08:00:00Z"), value: 100), + DataPoint(date: Date("2025-01-15T14:00:00Z"), value: 200), + DataPoint(date: Date("2025-01-15T20:00:00Z"), value: 150), + DataPoint(date: Date("2025-01-16T10:00:00Z"), value: 300), + DataPoint(date: Date("2025-01-17T10:00:00Z"), value: 250), + DataPoint(date: Date("2025-01-18T10:00:00Z"), value: 75) // Outside range + ] + + // Create date interval for Jan 15-17 (exclusive end) + let dateInterval = DateInterval( + start: Date("2025-01-15T00:00:00Z"), + end: Date("2025-01-18T00:00:00Z") + ) + + // Filter data points for the period + let filteredDataPoints = allDataPoints.filter { dataPoint in + dateInterval.contains(dataPoint.date) + } + + let result = aggregator.processPeriod( + dataPoints: filteredDataPoints, + dateInterval: dateInterval, + granularity: .day, + metric: .views + ) + + // Should have 3 days of data + #expect(result.dataPoints.count == 3) + + // Check aggregated values + #expect(result.dataPoints[0].date == Date("2025-01-15T00:00:00Z")) + #expect(result.dataPoints[0].value == 450) // 100 + 200 + 150 + + #expect(result.dataPoints[1].date == Date("2025-01-16T00:00:00Z")) + #expect(result.dataPoints[1].value == 300) + + #expect(result.dataPoints[2].date == Date("2025-01-17T00:00:00Z")) + #expect(result.dataPoints[2].value == 250) + + // Check total + #expect(result.total == 1000) // 450 + 300 + 250 + } + + @Test + func processPeriodHourlyGranularity() { + let aggregator = StatsDataAggregator(calendar: calendar) + + // Create test data with multiple values per hour + let dataPoints = [ + DataPoint(date: Date("2025-01-15T14:15:00Z"), value: 100), + DataPoint(date: Date("2025-01-15T14:30:00Z"), value: 200), + DataPoint(date: Date("2025-01-15T14:45:00Z"), value: 150), + DataPoint(date: Date("2025-01-15T15:10:00Z"), value: 300), + DataPoint(date: Date("2025-01-15T16:20:00Z"), value: 250) + ] + + // Create date interval for 3 hours + let dateInterval = DateInterval( + start: Date("2025-01-15T14:00:00Z"), + end: Date("2025-01-15T17:00:00Z") + ) + + // Filter data points for the period + let filteredDataPoints = dataPoints.filter { dataPoint in + dateInterval.contains(dataPoint.date) + } + + let result = aggregator.processPeriod( + dataPoints: filteredDataPoints, + dateInterval: dateInterval, + granularity: .hour, + metric: .views + ) + + // Should have 3 hours of data + #expect(result.dataPoints.count == 3) + + // Check aggregated values + #expect(result.dataPoints[0].value == 450) // 14:00 hour: 100 + 200 + 150 + #expect(result.dataPoints[1].value == 300) // 15:00 hour + #expect(result.dataPoints[2].value == 250) // 16:00 hour + + #expect(result.total == 1000) + } + + @Test + func processPeriodWithAveragedMetric() { + let aggregator = StatsDataAggregator(calendar: calendar) + + // Create test data + let dataPoints = [ + DataPoint(date: Date("2025-01-15T08:00:00Z"), value: 300), + DataPoint(date: Date("2025-01-15T14:00:00Z"), value: 600), + DataPoint(date: Date("2025-01-15T20:00:00Z"), value: 900), + DataPoint(date: Date("2025-01-16T10:00:00Z"), value: 400) + ] + + let dateInterval = DateInterval( + start: Date("2025-01-15T00:00:00Z"), + end: Date("2025-01-17T00:00:00Z") + ) + + // Filter data points for the period + let filteredDataPoints = dataPoints.filter { dataPoint in + dateInterval.contains(dataPoint.date) + } + + // Use timeOnSite which requires averaging + let result = aggregator.processPeriod( + dataPoints: filteredDataPoints, + dateInterval: dateInterval, + granularity: .day, + metric: .timeOnSite + ) + + // Values should be averaged per day + #expect(result.dataPoints[0].value == 600) // (300 + 600 + 900) / 3 + #expect(result.dataPoints[1].value == 400) // 400 / 1 + + // Total for averaged metrics is the average of all period values + #expect(result.total == 500) // (600 + 400) / 2 + } + + @Test + func processPeriodWithEmptyDateRange() { + let aggregator = StatsDataAggregator(calendar: calendar) + + let dataPoints = [ + DataPoint(date: Date("2025-01-15T10:00:00Z"), value: 100), + DataPoint(date: Date("2025-01-16T10:00:00Z"), value: 200) + ] + + // Date interval with no matching data + let dateInterval = DateInterval( + start: Date("2025-01-20T00:00:00Z"), + end: Date("2025-01-22T00:00:00Z") + ) + + // Filter data points for the period (should be empty) + let filteredDataPoints = dataPoints.filter { dataPoint in + dateInterval.contains(dataPoint.date) + } + + let result = aggregator.processPeriod( + dataPoints: filteredDataPoints, + dateInterval: dateInterval, + granularity: .day, + metric: .views + ) + + // Should still have dates but with zero values + #expect(result.dataPoints.count == 2) + #expect(result.dataPoints[0].value == 0) + #expect(result.dataPoints[1].value == 0) + #expect(result.total == 0) + } + + @Test + func processPeriodMonthlyGranularity() { + let aggregator = StatsDataAggregator(calendar: calendar) + + let dataPoints = [ + DataPoint(date: Date("2025-01-15T10:00:00Z"), value: 100), + DataPoint(date: Date("2025-01-25T10:00:00Z"), value: 200), + DataPoint(date: Date("2025-02-10T10:00:00Z"), value: 300), + DataPoint(date: Date("2025-02-20T10:00:00Z"), value: 400), + DataPoint(date: Date("2025-03-05T10:00:00Z"), value: 500) + ] + + let dateInterval = DateInterval( + start: Date("2025-01-01T00:00:00Z"), + end: Date("2025-03-01T00:00:00Z") + ) + + // Filter data points for the period + let filteredDataPoints = dataPoints.filter { dataPoint in + dateInterval.contains(dataPoint.date) + } + + let result = aggregator.processPeriod( + dataPoints: filteredDataPoints, + dateInterval: dateInterval, + granularity: .month, + metric: .views + ) + + // Should have 2 months (Jan and Feb) + #expect(result.dataPoints.count == 2) + #expect(result.dataPoints[0].value == 300) // Jan: 100 + 200 + #expect(result.dataPoints[1].value == 700) // Feb: 300 + 400 + #expect(result.total == 1000) + } } From c9f46c7d27f5c12198bbe8c524999f973a176bcb Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Wed, 23 Jul 2025 13:01:07 -0400 Subject: [PATCH 028/349] Update MockStatsService to use new processPeriod --- .../Services/Mocks/MockStatsService.swift | 43 +++++++------------ .../TrendViewModelTests.swift | 2 +- 2 files changed, 17 insertions(+), 28 deletions(-) diff --git a/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift b/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift index 3e312e35fd17..17fd0845afe7 100644 --- a/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift +++ b/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift @@ -43,15 +43,24 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { var total = SiteMetricsSet() var output: [SiteMetric: [DataPoint]] = [:] + + let aggregator = StatsDataAggregator(calendar: calendar) - for (metric, dataPoints) in hourlyData { - // This isn't efficient by any means but it will do for the mocking purposes - let filteredDataPoints = dataPoints.filter { + for (metric, allDataPoints) in hourlyData { + // Filter data points for the period + let filteredDataPoints = allDataPoints.filter { interval.start <= $0.date && $0.date < interval.end } - let dataPoints = aggregateData(filteredDataPoints, granularity: granularity, range: interval, metric: metric) - output[metric] = dataPoints - total[metric] = DataPoint.getTotalValue(for: dataPoints, metric: metric) + + // Use processPeriod to aggregate and normalize the data + let periodData = aggregator.processPeriod( + dataPoints: filteredDataPoints, + dateInterval: interval, + granularity: granularity, + metric: metric + ) + output[metric] = periodData.dataPoints + total[metric] = periodData.total } try? await Task.sleep(for: .milliseconds(Int.random(in: 200...500))) @@ -307,27 +316,7 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { ) } - // MARK: - Data Aggregation - - /// Aggregates raw data into data points based on granularity - private func aggregateData(_ dataPoints: [DataPoint], granularity: DateRangeGranularity, range: DateInterval, metric: SiteMetric) -> [DataPoint] { - let aggregator = StatsDataAggregator(calendar: calendar) - - // Step 1: Perform aggregation - let aggregatedData = aggregator.aggregate(dataPoints, granularity: granularity) - - // Step 2: Normalize data for metrics that need averaging - let normalizedData = aggregator.normalizeForMetric(aggregatedData, metric: metric) - - // Step 3: Generate complete data points - let dateSequence = aggregator.generateDateSequence(dateInterval: range, by: granularity.component) - - // Map dates to data points, using 0 for missing values - return dateSequence.map { date in - let aggregationDate = aggregator.makeAggegationDate(for: date, granularity: granularity) - return DataPoint(date: date, value: normalizedData[aggregationDate ?? date] ?? 0) - } - } + // MARK: - Data Loading /// Loads historical items from JSON files based on the data type private func loadHistoricalItems(for dataType: TopListItemType) -> [any TopListItem] { diff --git a/Modules/Tests/JetpackStatsTests/TrendViewModelTests.swift b/Modules/Tests/JetpackStatsTests/TrendViewModelTests.swift index 2c717f107148..8684d430cc9c 100644 --- a/Modules/Tests/JetpackStatsTests/TrendViewModelTests.swift +++ b/Modules/Tests/JetpackStatsTests/TrendViewModelTests.swift @@ -122,7 +122,7 @@ struct TrendViewModelTests { @Test("Formatted percentage string", arguments: [ (150, 100, "50%"), (175, 100, "75%"), - (1, 1000, "1K%"), + (1000, 1, "1K%"), (100, 100, "0%"), (125, 100, "25%"), (100, 0, "∞") From b34f15b0b406f5b62d56c711d7dc428706dfbfa2 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Wed, 23 Jul 2025 17:30:44 -0400 Subject: [PATCH 029/349] Cleanup --- .../Cards/StandaloneChartCard.swift | 88 ++++++++----------- 1 file changed, 37 insertions(+), 51 deletions(-) diff --git a/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift b/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift index c115b970a822..c927ae8025c8 100644 --- a/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift +++ b/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift @@ -18,8 +18,7 @@ struct StandaloneChartCard: View { @State private var dateRange: StatsDateRange @State private var selectedChartType: ChartType = .line @State private var isShowingDatePicker = false - @State private var cachedChartData: ChartData? - @State private var isComputingData = false + @State private var chartData: ChartData? @ScaledMetric private var chartHeight = 160 @@ -39,11 +38,9 @@ struct StandaloneChartCard: View { var body: some View { VStack(spacing: Constants.step1) { VStack(alignment: .leading, spacing: 8) { - HStack { - StatsCardTitleView(title: metric.localizedTitle) - Spacer() - } - + StatsCardTitleView(title: metric.localizedTitle) + .frame(maxWidth: .infinity, alignment: .leading) + // Show legend for current and comparison periods ChartLegendView( metric: metric, @@ -56,14 +53,17 @@ struct StandaloneChartCard: View { } // Chart content - if let chartData = cachedChartData { - chartContent(chartData: chartData) - .frame(height: chartHeight) - } else if isComputingData { - ProgressView() - .frame(height: chartHeight) + Group { + if let chartData = chartData { + chartContent(chartData: chartData) + } else { + chartContent(chartData: .mock(metric: .views, granularity: .day, range: dateRange)) + .redacted(reason: .placeholder) + .opacity(0.33) + } } - + .frame(height: chartHeight) + // Date range controls dateRangeControls } @@ -75,7 +75,7 @@ struct StandaloneChartCard: View { CustomDateRangePicker(dateRange: $dateRange) } .task(id: dateRange) { - await computeChartData() + await refreshChartData() } } @@ -92,7 +92,7 @@ struct StandaloneChartCard: View { } private var trend: TrendViewModel { - guard let chartData = cachedChartData else { + guard let chartData = chartData else { return TrendViewModel(currentValue: 0, previousValue: 0, metric: metric) } return TrendViewModel( @@ -101,18 +101,16 @@ struct StandaloneChartCard: View { metric: metric ) } - - @MainActor - private func computeChartData() async { - isComputingData = true - defer { isComputingData = false } - cachedChartData = await generateChartData( + private func refreshChartData() async { + let chartData = await generateChartData( dataPoints: dataPoints, dateRange: dateRange, metric: metric, context: context ) + guard !Task.isCancelled else { return } + self.chartData = chartData } // MARK: - Controls @@ -163,38 +161,26 @@ struct StandaloneChartCard: View { // Navigation controls HStack(spacing: 4) { - // Previous button - Button { - withAnimation(.spring(response: 0.3)) { - dateRange = dateRange.navigate(.backward) - } - } label: { - Image(systemName: "chevron.backward") - .font(.subheadline.weight(.medium)) - .foregroundColor(dateRange.canNavigate(in: .backward) ? .primary : Color(.quaternaryLabel)) - .frame(width: 36, height: 36) - .background(Color(.secondarySystemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - } - .disabled(!dateRange.canNavigate(in: .backward)) - - // Next button - Button { - withAnimation(.spring(response: 0.3)) { - dateRange = dateRange.navigate(.forward) - } - } label: { - Image(systemName: "chevron.forward") - .font(.subheadline.weight(.medium)) - .foregroundColor(dateRange.canNavigate(in: .forward) ? .primary : Color(.quaternaryLabel)) - .frame(width: 36, height: 36) - .background(Color(.secondarySystemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - } - .disabled(!dateRange.canNavigate(in: .forward)) + navigationButton(direction: .backward) + navigationButton(direction: .forward) } } } + + @ViewBuilder + private func navigationButton(direction: NavigationDirection) -> some View { + Button { + dateRange = dateRange.navigate(direction) + } label: { + Image(systemName: direction == .backward ? "chevron.backward" : "chevron.forward") + .font(.subheadline.weight(.medium)) + .foregroundColor(dateRange.canNavigate(in: direction) ? .primary : Color(.quaternaryLabel)) + .frame(width: 36, height: 36) + .background(Color(.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .disabled(!dateRange.canNavigate(in: direction)) + } } private func generateChartData(dataPoints: [DataPoint], dateRange: StatsDateRange, metric: SiteMetric, context: StatsContext) async -> ChartData { From 29f70dbf6f748f6bc3539da33c3d60e2bc264ce8 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Wed, 23 Jul 2025 17:55:51 -0400 Subject: [PATCH 030/349] Cleanup StandaloneChartCard --- .../Cards/StandaloneChartCard.swift | 22 ++++--------------- 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift b/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift index c927ae8025c8..c1036859faf6 100644 --- a/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift +++ b/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift @@ -168,7 +168,7 @@ struct StandaloneChartCard: View { } @ViewBuilder - private func navigationButton(direction: NavigationDirection) -> some View { + private func navigationButton(direction: Calendar.NavigationDirection) -> some View { Button { dateRange = dateRange.navigate(direction) } label: { @@ -237,7 +237,7 @@ private func generateChartData(dataPoints: [DataPoint], dateRange: StatsDateRang // MARK: - Preview -#Preview("Views Chart") { +#Preview { let calendar = Calendar.current let dateRange = calendar.makeDateRange(for: .last7Days) @@ -247,22 +247,8 @@ private func generateChartData(dataPoints: [DataPoint], dateRange: StatsDateRang initialDateRange: dateRange ) .cardStyle() - .padding() - .background(Color(.systemGroupedBackground)) - .environment(\.context, StatsContext.demo) -} - -#Preview("Likes Chart") { - let calendar = Calendar.current - let dateRange = calendar.makeDateRange(for: .last30Days) - - return StandaloneChartCard( - dataPoints: generateMockDataPoints(days: 365, valueRange: 10...50), - metric: .likes, - initialDateRange: dateRange - ) - .cardStyle() - .padding() + .padding(8) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) .background(Color(.systemGroupedBackground)) .environment(\.context, StatsContext.demo) } From 02fff70a818b48045859b06b4157d6186db35085 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Wed, 23 Jul 2025 18:00:43 -0400 Subject: [PATCH 031/349] Refactor PostStatsDetailsView --- .../Screens/PostStatsDetailsView.swift | 113 +++++++++--------- 1 file changed, 59 insertions(+), 54 deletions(-) diff --git a/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift b/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift index 2f2e2ff6e8e4..627c72339074 100644 --- a/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift +++ b/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift @@ -11,56 +11,18 @@ struct PostStatsDetailsView: View { @State private var dataPoints: [DataPoint] = [] @State private var isLoading = true @State private var error: Error? - - init(post: TopListData.Post) { + + private let initialDateRange: StatsDateRange + + init(post: TopListData.Post, dateRange: StatsDateRange) { self.post = post + self.initialDateRange = dateRange } var body: some View { ScrollView { VStack(spacing: Constants.step2) { - if let details { - PostHeaderCard(post: post, details: details, postLikes: postLikes, context: context) - .cardStyle() - - // Views Over Time Chart - if !dataPoints.isEmpty { - let calendar = Calendar.current - let dateRange = calendar.makeDateRange(for: .last7Days) - StandaloneChartCard(dataPoints: dataPoints, metric: .views, initialDateRange: dateRange) - .cardStyle() - } - - // Peak Performance Card - if details.highestMonth != nil || details.highestDayAverage != nil || details.highestWeekAverage != nil { - PeakPerformanceCard(details: details) - .cardStyle() - } - - // Weekly Trends Chart - if !details.recentWeeks.isEmpty { - WeeklyTrendsCard(weeks: details.recentWeeks, context: context) - .cardStyle() - } - - // Yearly Summary - if !details.yearlyTotals.isEmpty { - YearlySummaryCard( - yearlyTotals: details.yearlyTotals, - overallAverages: details.overallAverages, - monthlyBreakdown: details.monthlyBreakdown - ) - .cardStyle() - } - } else if isLoading { - ProgressView() - .frame(maxWidth: .infinity, minHeight: 200) - .cardStyle() - } else if let error { - SimpleErrorView(error: error) - .frame(maxWidth: .infinity, minHeight: 200) - .cardStyle() - } + contents } .padding(.vertical, Constants.step1) } @@ -68,10 +30,55 @@ struct PostStatsDetailsView: View { .navigationTitle(Strings.PostDetails.title) .navigationBarTitleDisplayMode(.inline) .task { + try? await Task.sleep(for: .seconds(5)) await loadPostDetails() } } - + + @ViewBuilder + private var contents: some View { + if let details { + PostHeaderCard(post: post, details: details, postLikes: postLikes, context: context) + .cardStyle() + + // Views Over Time Chart + if !dataPoints.isEmpty { + StandaloneChartCard(dataPoints: dataPoints, metric: .views, initialDateRange: initialDateRange) + .cardStyle() + } + + // Peak Performance Card + if details.highestMonth != nil || details.highestDayAverage != nil || details.highestWeekAverage != nil { + PeakPerformanceCard(details: details) + .cardStyle() + } + + // Weekly Trends Chart + if !details.recentWeeks.isEmpty { + WeeklyTrendsCard(weeks: details.recentWeeks, context: context) + .cardStyle() + } + + // Yearly Summary + if !details.yearlyTotals.isEmpty { + YearlySummaryCard( + yearlyTotals: details.yearlyTotals, + overallAverages: details.overallAverages, + monthlyBreakdown: details.monthlyBreakdown + ) + .cardStyle() + } + } else if isLoading { + ProgressView() + .frame(maxWidth: .infinity, minHeight: 200) + .cardStyle() + } else if let error { + SimpleErrorView(error: error) + .frame(maxWidth: .infinity, minHeight: 200) + .cardStyle() + } + } + private func loadPostDetails() async { guard let postId = post.postId, let postIdInt = Int(postId) else { return } @@ -99,12 +106,12 @@ struct PostStatsDetailsView: View { } private func convertToDataPoints(from data: [StatsPostViews]) -> [DataPoint] { + // Convert DateComponents to Date using site timezone (similar to how StatsService does it) + var calendar = context.calendar + calendar.timeZone = context.timeZone + // Convert StatsPostViews to DataPoints using the site timezone return data.compactMap { postView in - // Convert DateComponents to Date using site timezone (similar to how StatsService does it) - var calendar = context.calendar - calendar.timeZone = context.timeZone - guard let date = calendar.date(from: postView.date) else { return nil } return DataPoint(date: date, value: postView.viewsCount) } @@ -643,7 +650,7 @@ private struct MonthCellExpanded: View { private var heatmapColor: Color { if viewsCount == 0 { - return Color(UIColor.systemGray6) + return Color(UIColor.secondarySystemBackground) } // Use ColorStudio blue gradient @@ -659,9 +666,6 @@ private struct MonthCellExpanded: View { } } - - - #Preview { NavigationStack { PostStatsDetailsView( @@ -673,7 +677,8 @@ private struct MonthCellExpanded: View { type: "post", author: nil, metrics: .init(views: 45892, likes: 26, comments: 487) - ) + ), + dateRange: Calendar.demo.makeDateRange(for: .thisYear) ) .environment(\.context, StatsContext.demo) } From 4dafdaa458b7b6b62b721ee1972c9ab23236c912 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Wed, 23 Jul 2025 18:46:56 -0400 Subject: [PATCH 032/349] Improve loading state --- .../Cards/StandaloneChartCard.swift | 11 +- .../Screens/PostStatsDetailsView.swift | 197 +++++++++++------- .../Services/Data/PostLikeUser.swift | 47 +++-- .../Services/Data/SiteMetricsSet.swift | 13 ++ .../Services/Data/TopListChartData.swift | 1 + .../Services/Data/TopListData.swift | 1 + .../Services/Mocks/MockStatsService.swift | 57 ++--- .../JetpackStats/Services/StatsService.swift | 23 +- .../Services/StatsServiceProtocol.swift | 2 +- 9 files changed, 200 insertions(+), 152 deletions(-) diff --git a/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift b/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift index c1036859faf6..5d749934b484 100644 --- a/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift +++ b/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift @@ -14,7 +14,7 @@ struct StandaloneChartCard: View { /// The metric type being displayed (e.g., views, likes, comments) let metric: SiteMetric - + @State private var dateRange: StatsDateRange @State private var selectedChartType: ChartType = .line @State private var isShowingDatePicker = false @@ -23,7 +23,9 @@ struct StandaloneChartCard: View { @ScaledMetric private var chartHeight = 160 @Environment(\.context) private var context - + + @Environment(\.redactionReasons) private var redactionReasons + /// Creates a new standalone chart card. /// - Parameters: /// - dataPoints: The array of data points to display @@ -34,7 +36,7 @@ struct StandaloneChartCard: View { self.metric = metric self._dateRange = State(initialValue: initialDateRange) } - + var body: some View { VStack(spacing: Constants.step1) { VStack(alignment: .leading, spacing: 8) { @@ -54,8 +56,9 @@ struct StandaloneChartCard: View { // Chart content Group { - if let chartData = chartData { + if let chartData { chartContent(chartData: chartData) + .opacity(redactionReasons.contains(.placeholder) ? 0.2 : 1.0) } else { chartContent(chartData: .mock(metric: .views, granularity: .day, range: dateRange)) .redacted(reason: .placeholder) diff --git a/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift b/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift index 627c72339074..d2084b57d8d8 100644 --- a/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift +++ b/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift @@ -7,7 +7,7 @@ struct PostStatsDetailsView: View { @Environment(\.context) private var context @State private var details: StatsPostDetails? - @State private var postLikes: PostLikes? + @State private var postLikes: PostLikesData? @State private var dataPoints: [DataPoint] = [] @State private var isLoading = true @State private var error: Error? @@ -30,23 +30,31 @@ struct PostStatsDetailsView: View { .navigationTitle(Strings.PostDetails.title) .navigationBarTitleDisplayMode(.inline) .task { - try? await Task.sleep(for: .seconds(5)) await loadPostDetails() } } @ViewBuilder private var contents: some View { - if let details { - PostHeaderCard(post: post, details: details, postLikes: postLikes, context: context) - .cardStyle() + PostHeaderCard( + post: post, + details: details, + postLikes: postLikes, + isLoading: isLoading + ) + .cardStyle() - // Views Over Time Chart - if !dataPoints.isEmpty { - StandaloneChartCard(dataPoints: dataPoints, metric: .views, initialDateRange: initialDateRange) - .cardStyle() - } + // Views Over Time Chart + if !dataPoints.isEmpty { + StandaloneChartCard(dataPoints: dataPoints, metric: .views, initialDateRange: initialDateRange) + .cardStyle() + } else if isLoading { + StandaloneChartCard(dataPoints: mockDataPoints, metric: .views, initialDateRange: initialDateRange) + .cardStyle() + .redacted(reason: .placeholder) + } + if let details { // Peak Performance Card if details.highestMonth != nil || details.highestDayAverage != nil || details.highestWeekAverage != nil { PeakPerformanceCard(details: details) @@ -68,10 +76,6 @@ struct PostStatsDetailsView: View { ) .cardStyle() } - } else if isLoading { - ProgressView() - .frame(maxWidth: .infinity, minHeight: 200) - .cardStyle() } else if let error { SimpleErrorView(error: error) .frame(maxWidth: .infinity, minHeight: 200) @@ -83,7 +87,7 @@ struct PostStatsDetailsView: View { guard let postId = post.postId, let postIdInt = Int(postId) else { return } async let detailsTask = context.service.getPostDetails(for: postIdInt) - async let likesTask: PostLikes? = { + async let likesTask: PostLikesData? = { if (post.metrics.likes ?? 0) > 0 { return try? await context.service.getPostLikes(for: postIdInt, count: 20) } @@ -116,65 +120,85 @@ struct PostStatsDetailsView: View { return DataPoint(date: date, value: postView.viewsCount) } } + + private var mockDataPoints: [DataPoint] { + ChartData.mock( + metric: .views, + granularity: initialDateRange.dateInterval.preferredGranularity, + range: initialDateRange + ).currentData + } } private struct PostHeaderCard: View { let post: TopListData.Post - let details: StatsPostDetails - let postLikes: PostLikes? - let context: StatsContext - + let details: StatsPostDetails? + let postLikes: PostLikesData? + var isLoading: Bool + + @Environment(\.context) var context + var body: some View { VStack(alignment: .leading, spacing: Constants.step2) { - VStack(alignment: .leading, spacing: 4) { - Text(post.title) - .font(.title3.weight(.semibold)) - .multilineTextAlignment(.leading) - .lineLimit(3) - - if let dateGMT = details.post?.dateGMT { - HStack(spacing: 6) { - Text(Strings.PostDetails.published(formatPublishedDate(dateGMT))) - .font(.subheadline) - .foregroundColor(.secondary) - - // Permalink button - if let permalink = details.post?.permalink, let url = URL(string: permalink) { - Button(action: { - UIApplication.shared.open(url) - }) { - Image(systemName: "link") - .font(.footnote) - .foregroundColor(.accentColor) - } - } - } - } + postDetailsView - // Likes strip - if let postLikes, !postLikes.users.isEmpty { - PostLikesStrip(likes: postLikes) - .padding(.top, Constants.step2) - } + if let postLikes, !postLikes.users.isEmpty { + PostLikesStrip(likes: postLikes) + } else if isLoading { + PostLikesStrip(likes: .mock) + .redacted(reason: .placeholder) } Divider() - // Metrics with visual separation - HStack(spacing: Constants.step3) { - MetricView(metric: .views, value: details.totalViewsCount) - if let likesCount = post.metrics.likes { - MetricView(metric: .likes, value: likesCount) - } - if let commentCount = details.post?.commentCount, let count = Int(commentCount) { - MetricView(metric: .comments, value: count) - } + if let metrics { + PostStatsMetricsStripView(metrics: metrics) + } else if isLoading { + PostStatsMetricsStripView(metrics: .mock) + .redacted(reason: .placeholder) } } .frame(maxWidth: .infinity, alignment: .leading) .padding(EdgeInsets(top: Constants.step2, leading: Constants.step2, bottom: Constants.step1, trailing: Constants.step2)) } - + + private var postDetailsView: some View { + VStack(alignment: .leading, spacing: 4) { + Text(post.title) + .font(.title3.weight(.semibold)) + .multilineTextAlignment(.leading) + .lineLimit(3) + + if let dateGMT = post.date ?? details?.post?.dateGMT { + HStack(spacing: 6) { + Text(Strings.PostDetails.published(formatPublishedDate(dateGMT))) + .font(.subheadline) + .foregroundColor(.secondary) + + // Permalink button + if let postURL = post.postURL ?? details?.post?.permalink.flatMap(URL.init) { + Link(destination: postURL) { + Image(systemName: "link") + .font(.footnote) + .foregroundColor(.accentColor) + } + } + } + } + } + } + + private var metrics: SiteMetricsSet? { + guard let details else { + return nil + } + return SiteMetricsSet( + views: details.totalViewsCount, + likes: postLikes?.totalCount, + comments: details.post?.commentCount.flatMap { Int($0) } + ) + } + private func formatPublishedDate(_ date: Date) -> String { let formatter = DateFormatter() formatter.dateStyle = .medium @@ -184,35 +208,54 @@ private struct PostHeaderCard: View { } } -private struct MetricView: View { - let metric: SiteMetric - let value: Int +private struct PostStatsMetricsStripView: View { + let metrics: SiteMetricsSet var body: some View { - VStack(alignment: .leading, spacing: 0) { - VStack(alignment: .leading, spacing: 2) { - Image(systemName: metric.systemImage) - .font(.caption2.weight(.medium)) - .foregroundColor(.secondary) + HStack(spacing: Constants.step2) { + ForEach([SiteMetric.views, .visitors, .comments]) { metric in + MetricView(metric: metric, value: metrics[metric]) + } + } + } - Text(metric.localizedTitle.uppercased()) - .font(.caption.weight(.medium)) - .foregroundColor(.secondary) + struct MetricView: View { + let metric: SiteMetric + let value: Int? + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 2) { + Image(systemName: metric.systemImage) + .font(.caption2.weight(.medium)) + .foregroundColor(.secondary) + + Text(metric.localizedTitle.uppercased()) + .font(.caption.weight(.medium)) + .foregroundColor(.secondary) + } + + Text(formattedValue) + .contentTransition(.numericText()) + .animation(.spring, value: value) + .font(Font.make(.recoleta, textStyle: .title, weight: .medium)) + .foregroundColor(.primary) } + .lineLimit(1) + .frame(minWidth: 78, alignment: .leading) + } - Text(StatsValueFormatter(metric: metric).format(value: value)) - .contentTransition(.numericText()) - .animation(.spring, value: value) - .font(Font.make(.recoleta, textStyle: .title, weight: .medium)) - .foregroundColor(.primary) + var formattedValue: String { + guard let value else { + return "–" + } + return StatsValueFormatter(metric: metric).format(value: value) } - .lineLimit(1) - .frame(minWidth: 78, alignment: .leading) } } private struct PostLikesStrip: View { - let likes: PostLikes + let likes: PostLikesData private let avatarSize: CGFloat = 28 private let maxVisibleAvatars = 5 diff --git a/Modules/Sources/JetpackStats/Services/Data/PostLikeUser.swift b/Modules/Sources/JetpackStats/Services/Data/PostLikeUser.swift index 0266790db55b..1bec17037824 100644 --- a/Modules/Sources/JetpackStats/Services/Data/PostLikeUser.swift +++ b/Modules/Sources/JetpackStats/Services/Data/PostLikeUser.swift @@ -1,23 +1,38 @@ import Foundation -public struct PostLikeUser: Equatable, Identifiable, Sendable { - public let id: Int - public let name: String - public let avatarURL: URL? +struct PostLikesData: Equatable, Sendable { + let users: [PostLikeUser] + let totalCount: Int - public init(id: Int, name: String, avatarURL: URL? = nil) { - self.id = id - self.name = name - self.avatarURL = avatarURL - } -} - -public struct PostLikes: Equatable, Sendable { - public let users: [PostLikeUser] - public let totalCount: Int - - public init(users: [PostLikeUser], totalCount: Int) { + init(users: [PostLikeUser], totalCount: Int) { self.users = users self.totalCount = totalCount } + + struct PostLikeUser: Equatable, Identifiable, Sendable { + let id: Int + let name: String + let avatarURL: URL? + + init(id: Int, name: String, avatarURL: URL? = nil) { + self.id = id + self.name = name + self.avatarURL = avatarURL + } + } + + static let mock = PostLikesData(users: [ + PostLikeUser(id: 0, name: "Alex Chen"), + PostLikeUser(id: 1, name: "Maya Rodriguez"), + PostLikeUser(id: 2, name: "James Wilson"), + PostLikeUser(id: 3, name: "Zara Okafor"), + PostLikeUser(id: 4, name: "Liam O'Connor"), + PostLikeUser(id: 5, name: "Priya Patel"), + PostLikeUser(id: 6, name: "Noah Kim"), + PostLikeUser(id: 7, name: "Sofia Andersson"), + PostLikeUser(id: 8, name: "Marcus Thompson"), + PostLikeUser(id: 9, name: "Fatima Al-Zahra"), + PostLikeUser(id: 10, name: "Diego Santos"), + PostLikeUser(id: 11, name: "Emma Johansson") + ], totalCount: 12) } diff --git a/Modules/Sources/JetpackStats/Services/Data/SiteMetricsSet.swift b/Modules/Sources/JetpackStats/Services/Data/SiteMetricsSet.swift index 189c598a069a..0dad946ef515 100644 --- a/Modules/Sources/JetpackStats/Services/Data/SiteMetricsSet.swift +++ b/Modules/Sources/JetpackStats/Services/Data/SiteMetricsSet.swift @@ -38,4 +38,17 @@ struct SiteMetricsSet: Codable { } } } + + static var mock: SiteMetricsSet { + SiteMetricsSet( + views: Int.random(in: 10...10000), + visitors: Int.random(in: 10...1000), + likes: Int.random(in: 10...1000), + comments: Int.random(in: 10...1000), + posts: Int.random(in: 10...100), + bounceRate: Int.random(in: 50...80), + timeOnSite: Int.random(in: 10...200), + downloads: Int.random(in: 10...500) + ) + } } diff --git a/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift b/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift index b43b24a948f0..1ab8013783da 100644 --- a/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift +++ b/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift @@ -109,6 +109,7 @@ extension TopListChartData { return TopListData.Post( title: data.0, postId: "\(index + 1)", + postURL: nil, date: nil, pageId: nil, type: nil, diff --git a/Modules/Sources/JetpackStats/Services/Data/TopListData.swift b/Modules/Sources/JetpackStats/Services/Data/TopListData.swift index 0e09c5cc1ab3..521362a90c55 100644 --- a/Modules/Sources/JetpackStats/Services/Data/TopListData.swift +++ b/Modules/Sources/JetpackStats/Services/Data/TopListData.swift @@ -13,6 +13,7 @@ extension TopListData { struct Post: Codable, TopListItem { let title: String let postId: String? + var postURL: URL? let date: Date? let pageId: String? let type: String? diff --git a/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift b/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift index 17fd0845afe7..e389c81af33b 100644 --- a/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift +++ b/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift @@ -268,52 +268,31 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { return details } - func getPostLikes(for postID: Int, count: Int) async throws -> PostLikes { + func getPostLikes(for postID: Int, count: Int) async throws -> PostLikesData { // Simulate network delay try? await Task.sleep(for: .milliseconds(Int.random(in: 200...500))) - - // Generate mock users who liked the post - let mockUsers = [ - PostLikeUser( - id: 1, - name: "Sarah Chen", - avatarURL: Bundle.module.path(forResource: "author1", ofType: "jpg").map { URL(filePath: $0) } - ), - PostLikeUser( - id: 2, - name: "Marcus Johnson", - avatarURL: Bundle.module.path(forResource: "author2", ofType: "jpg").map { URL(filePath: $0) } - ), - PostLikeUser( - id: 3, - name: "Emily Rodriguez", - avatarURL: Bundle.module.path(forResource: "author3", ofType: "jpg").map { URL(filePath: $0) } - ), - PostLikeUser( - id: 4, - name: "Alex Thompson", - avatarURL: Bundle.module.path(forResource: "author4", ofType: "jpg").map { URL(filePath: $0) } - ), - PostLikeUser( - id: 5, - name: "Nina Patel", - avatarURL: Bundle.module.path(forResource: "author5", ofType: "jpg").map { URL(filePath: $0) } - ), - PostLikeUser( - id: 6, - name: "James Wilson", - avatarURL: Bundle.module.path(forResource: "author6", ofType: "jpg").map { URL(filePath: $0) } + + func makeUser(id: Int, name: String) -> PostLikesData.PostLikeUser { + PostLikesData.PostLikeUser( + id: id, + name: name, + avatarURL: Bundle.module.path(forResource: "author\(id)", ofType: "jpg").map { URL(filePath: $0) } ) + } + + let mockUsers = [ + makeUser(id: 1, name: "Sarah Chen"), + makeUser(id: 2, name: "Marcus Johnson"), + makeUser(id: 3, name: "Emily Rodriguez"), + makeUser(id: 4, name: "Alex Thompson"), + makeUser(id: 5, name: "Nina Patel"), + makeUser(id: 6, name: "James Wilson") ] - - // Return requested number of users + let requestedCount = min(count, mockUsers.count) let selectedUsers = Array(mockUsers.prefix(requestedCount)) - return PostLikes( - users: selectedUsers, - totalCount: 26 // Match the mock post's like count - ) + return PostLikesData(users: selectedUsers, totalCount: 26) } // MARK: - Data Loading diff --git a/Modules/Sources/JetpackStats/Services/StatsService.swift b/Modules/Sources/JetpackStats/Services/StatsService.swift index 8335b06a8614..7b8d40e03663 100644 --- a/Modules/Sources/JetpackStats/Services/StatsService.swift +++ b/Modules/Sources/JetpackStats/Services/StatsService.swift @@ -191,7 +191,7 @@ actor StatsService: StatsServiceProtocol { try await service.getDetails(forPostID: postID) } - func getPostLikes(for postID: Int, count: Int) async throws -> PostLikes { + func getPostLikes(for postID: Int, count: Int) async throws -> PostLikesData { // Create PostServiceRemoteREST instance let postService = PostServiceRemoteREST( wordPressComRestApi: api, @@ -207,13 +207,13 @@ actor StatsService: StatsServiceProtocol { excludeUserIDs: nil, success: { users, found in let likeUsers = users.map { remoteLike in - PostLikeUser( + PostLikesData.PostLikeUser( id: remoteLike.userID.intValue, name: remoteLike.displayName ?? remoteLike.username ?? "Unknown", avatarURL: remoteLike.avatarURL.flatMap(URL.init) ) } - let postLikes = PostLikes(users: likeUsers, totalCount: found.intValue) + let postLikes = PostLikesData(users: likeUsers, totalCount: found.intValue) continuation.resume(returning: postLikes) }, failure: { error in @@ -314,13 +314,12 @@ actor StatsService: StatsServiceProtocol { TopListData.Post( title: post.title, postId: String(post.postID), + postURL: post.postURL, date: post.date.flatMap(dateFormatter.date), pageId: nil, type: post.kind.description, author: nil, - metrics: SiteMetricsSet( - views: post.viewsCount - ) + metrics: SiteMetricsSet(views: post.viewsCount) ) } return TopListData(items: items) @@ -331,9 +330,7 @@ actor StatsService: StatsServiceProtocol { TopListData.Referrer( name: referrer.title, domain: referrer.url?.host, - metrics: SiteMetricsSet( - views: referrer.viewsCount - ) + metrics: SiteMetricsSet(views: referrer.viewsCount) ) } @@ -346,9 +343,7 @@ actor StatsService: StatsServiceProtocol { country: country.name, flag: countryCodeToEmoji(country.code), countryCode: country.code, - metrics: SiteMetricsSet( - views: country.viewsCount - ) + metrics: SiteMetricsSet(views: country.viewsCount) ) } @@ -361,9 +356,7 @@ actor StatsService: StatsServiceProtocol { name: author.name, userId: author.name, // NOTE: WordPressKit doesn't provide user ID role: nil, - metrics: SiteMetricsSet( - views: author.viewsCount - ), + metrics: SiteMetricsSet(views: author.viewsCount), avatarURL: author.iconURL ) } diff --git a/Modules/Sources/JetpackStats/Services/StatsServiceProtocol.swift b/Modules/Sources/JetpackStats/Services/StatsServiceProtocol.swift index dbb8edfdb4ef..8f307a90df1a 100644 --- a/Modules/Sources/JetpackStats/Services/StatsServiceProtocol.swift +++ b/Modules/Sources/JetpackStats/Services/StatsServiceProtocol.swift @@ -11,5 +11,5 @@ protocol StatsServiceProtocol: AnyObject, Sendable { func getTopListData(_ item: TopListItemType, metric: SiteMetric, interval: DateInterval, granularity: DateRangeGranularity) 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 -> PostLikes + func getPostLikes(for postID: Int, count: Int) async throws -> PostLikesData } From cff361f9c39e43661cab0a75a75a7cbe808e59b9 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Wed, 23 Jul 2025 20:47:20 -0400 Subject: [PATCH 033/349] Add no likes placeholder --- .../Screens/PostStatsDetailsView.swift | 136 +++++++++++------- .../Services/Data/TopListChartData.swift | 3 +- .../Services/Data/TopListData.swift | 5 +- .../JetpackStats/Services/StatsService.swift | 3 +- Modules/Sources/JetpackStats/Strings.swift | 1 + .../JetpackStats/Views/AvatarView.swift | 13 +- 6 files changed, 93 insertions(+), 68 deletions(-) diff --git a/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift b/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift index d2084b57d8d8..95a3aeb2cee5 100644 --- a/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift +++ b/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift @@ -84,21 +84,22 @@ struct PostStatsDetailsView: View { } private func loadPostDetails() async { - guard let postId = post.postId, let postIdInt = Int(postId) else { return } - + guard let postId = post.postID, let postIdInt = Int(postId) else { + self.error = URLError(.unknown, userInfo: [NSLocalizedDescriptionKey: Strings.Errors.generic]) + self.isLoading = false + return + } + async let detailsTask = context.service.getPostDetails(for: postIdInt) async let likesTask: PostLikesData? = { - if (post.metrics.likes ?? 0) > 0 { - return try? await context.service.getPostLikes(for: postIdInt, count: 20) - } - return nil + try? await context.service.getPostLikes(for: postIdInt, count: 20) }() do { let (details, likes) = try await (detailsTask, likesTask) self.details = details self.postLikes = likes - + // Convert data to DataPoints using site timezone self.dataPoints = convertToDataPoints(from: details.data) @@ -142,10 +143,10 @@ private struct PostHeaderCard: View { VStack(alignment: .leading, spacing: Constants.step2) { postDetailsView - if let postLikes, !postLikes.users.isEmpty { - PostLikesStrip(likes: postLikes) + if let postLikes { + PostLikesStripView(likes: postLikes) } else if isLoading { - PostLikesStrip(likes: .mock) + PostLikesStripView(likes: .mock) .redacted(reason: .placeholder) } @@ -213,7 +214,7 @@ private struct PostStatsMetricsStripView: View { var body: some View { HStack(spacing: Constants.step2) { - ForEach([SiteMetric.views, .visitors, .comments]) { metric in + ForEach([SiteMetric.views, .likes, .comments]) { metric in MetricView(metric: metric, value: metrics[metric]) } } @@ -254,58 +255,90 @@ private struct PostStatsMetricsStripView: View { } } -private struct PostLikesStrip: View { +private struct PostLikesStripView: View { let likes: PostLikesData private let avatarSize: CGFloat = 28 - private let maxVisibleAvatars = 5 - + private let maxVisibleAvatars = 6 + var body: some View { + if likes.users.isEmpty { + emptyStateView + } else { + HStack { + avatars + Spacer() + viewMore + } + } + } + + // Overlapping avatars + private var avatars: some View { + HStack(spacing: -8) { + ForEach(likes.users.prefix(maxVisibleAvatars)) { user in + AvatarView(name: user.name, imageURL: user.avatarURL, size: avatarSize, backgroundColor: Color(.secondarySystemBackground)) + .overlay( + Circle() + .stroke(Color(UIColor.systemBackground), lineWidth: 1) + ) + } + + // Show additional count if there are more users + if likes.totalCount > maxVisibleAvatars { + Text("+\((likes.totalCount - maxVisibleAvatars).formatted(.number.notation(.compactName)))") + .font(.caption2.weight(.medium)) + .foregroundColor(.primary.opacity(0.8)) + .padding(.horizontal, 4) + .frame(height: avatarSize + 2) + .frame(minWidth: avatarSize + 2) + .background { + RoundedRectangle(cornerRadius: 20) + .fill(Color(UIColor.secondarySystemBackground)) + } + .overlay( + RoundedRectangle(cornerRadius: 20) + .stroke(Color(UIColor.systemBackground), lineWidth: 1) + ) + } + } + } + + private var viewMore: some View { + // Likes button + Button(action: { + // TODO: Navigate to likes detail screen + }) { + HStack(spacing: 4) { + Text(Strings.PostDetails.likesCount(likes.totalCount)) + .font(.subheadline) + .foregroundColor(.primary) + + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundColor(.secondary.opacity(0.66)) + } + } + } + + private var emptyStateView: some View { HStack { - // Overlapping avatars HStack(spacing: -8) { - ForEach(likes.users.prefix(maxVisibleAvatars)) { user in - AvatarView(name: user.name, imageURL: user.avatarURL, size: avatarSize) - .overlay( - Circle() - .stroke(Color(UIColor.systemBackground), lineWidth: 2) - ) - } - - // Show additional count if there are more users - if likes.totalCount > maxVisibleAvatars { + ForEach(0...2, id: \.self) { _ in Circle() - .fill(Color(UIColor.systemGray5)) .frame(width: avatarSize, height: avatarSize) - .overlay( - Text("+\(likes.totalCount - maxVisibleAvatars)") - .font(.caption.weight(.medium)) - .foregroundColor(.secondary) - ) + .foregroundStyle(Color(.secondarySystemBackground)) .overlay( Circle() - .stroke(Color(UIColor.systemBackground), lineWidth: 2) + .stroke(Color(UIColor.systemBackground), lineWidth: 1) ) } } - - Spacer() - - // Likes button - Button(action: { - // TODO: Navigate to likes detail screen - }) { - HStack(spacing: 4) { - Text(Strings.PostDetails.likesCount(likes.totalCount)) - .font(.subheadline) - .foregroundColor(.primary) - - Image(systemName: "chevron.right") - .font(.caption.weight(.semibold)) - .foregroundColor(.secondary.opacity(0.66)) - } - } + Text(Strings.PostDetails.noLikesYet) + .font(.subheadline) + .foregroundColor(.secondary) } + .lineLimit(1) } } @@ -488,7 +521,7 @@ private struct DayCell: View { private var heatmapColor: Color { if viewsCount == 0 { - return Color(UIColor.systemGray6) + return Color(UIColor.secondarySystemBackground) } // Use ColorStudio blue gradient @@ -714,9 +747,8 @@ private struct MonthCellExpanded: View { PostStatsDetailsView( post: .init( title: "Matter Smart Home Protocol Still Doesn't Matter: A Year Later", - postId: "12345", + postID: "12345", date: .now, - pageId: nil, type: "post", author: nil, metrics: .init(views: 45892, likes: 26, comments: 487) diff --git a/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift b/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift index 1ab8013783da..aac38573ce68 100644 --- a/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift +++ b/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift @@ -108,10 +108,9 @@ extension TopListChartData { let metrics = createMetrics(baseValue: baseValue, metric: metric) return TopListData.Post( title: data.0, - postId: "\(index + 1)", + postID: "\(index + 1)", postURL: nil, date: nil, - pageId: nil, type: nil, author: data.1, metrics: metrics diff --git a/Modules/Sources/JetpackStats/Services/Data/TopListData.swift b/Modules/Sources/JetpackStats/Services/Data/TopListData.swift index 521362a90c55..fca6bbe70592 100644 --- a/Modules/Sources/JetpackStats/Services/Data/TopListData.swift +++ b/Modules/Sources/JetpackStats/Services/Data/TopListData.swift @@ -12,15 +12,14 @@ protocol TopListItem: Codable, Sendable, Identifiable { extension TopListData { struct Post: Codable, TopListItem { let title: String - let postId: String? + let postID: String? var postURL: URL? let date: Date? - let pageId: String? let type: String? let author: String? var metrics: SiteMetricsSet - var id: String { postId ?? pageId ?? title } + var id: String { postID ?? title } } struct Referrer: Codable, TopListItem { diff --git a/Modules/Sources/JetpackStats/Services/StatsService.swift b/Modules/Sources/JetpackStats/Services/StatsService.swift index 7b8d40e03663..cf281fc7f7f5 100644 --- a/Modules/Sources/JetpackStats/Services/StatsService.swift +++ b/Modules/Sources/JetpackStats/Services/StatsService.swift @@ -313,10 +313,9 @@ actor StatsService: StatsServiceProtocol { let items = posts.map { post in TopListData.Post( title: post.title, - postId: String(post.postID), + postID: String(post.postID), postURL: post.postURL, date: post.date.flatMap(dateFormatter.date), - pageId: nil, type: post.kind.description, author: nil, metrics: SiteMetricsSet(views: post.viewsCount) diff --git a/Modules/Sources/JetpackStats/Strings.swift b/Modules/Sources/JetpackStats/Strings.swift index e0768a4e3c66..94938ffe57ec 100644 --- a/Modules/Sources/JetpackStats/Strings.swift +++ b/Modules/Sources/JetpackStats/Strings.swift @@ -144,6 +144,7 @@ enum Strings { static let avgPerDay = AppLocalizedString("jetpackStats.postDetails.avgPerDay", value: "avg/day", comment: "Average per day abbreviation") // Likes + static let noLikesYet = AppLocalizedString("jetpackStats.postDetails.noLikesYet", value: "No likes yet", comment: "Label") static func likesCount(_ count: Int) -> String { let format = count == 1 ? AppLocalizedString("jetpackStats.postDetails.like", value: "%1$d like", comment: "Singular like count. %1$d is the number.") diff --git a/Modules/Sources/JetpackStats/Views/AvatarView.swift b/Modules/Sources/JetpackStats/Views/AvatarView.swift index 3e9f52ffcdc2..28e7487115d3 100644 --- a/Modules/Sources/JetpackStats/Views/AvatarView.swift +++ b/Modules/Sources/JetpackStats/Views/AvatarView.swift @@ -4,14 +4,9 @@ import AsyncImageKit struct AvatarView: View { let name: String - let imageURL: URL? - let size: CGFloat - - init(name: String, imageURL: URL? = nil, size: CGFloat = 36) { - self.name = name - self.imageURL = imageURL - self.size = size - } + var imageURL: URL? + var size: CGFloat = 36 + var backgroundColor = Color(.systemBackground) var body: some View { if let imageURL { @@ -31,7 +26,7 @@ struct AvatarView: View { private var placeholderView: some View { Circle() - .fill(Color(.systemBackground)) + .fill(backgroundColor) .frame(width: size, height: size) .overlay( Text(initials) From edc4d2c2f5ccb17542db6f7a1bd53f48ffe11bdb Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Wed, 23 Jul 2025 20:53:22 -0400 Subject: [PATCH 034/349] Add animations --- .../Screens/PostStatsDetailsView.swift | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift b/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift index 95a3aeb2cee5..4395b6ae652a 100644 --- a/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift +++ b/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift @@ -94,19 +94,20 @@ struct PostStatsDetailsView: View { async let likesTask: PostLikesData? = { try? await context.service.getPostLikes(for: postIdInt, count: 20) }() - + do { let (details, likes) = try await (detailsTask, likesTask) - self.details = details - self.postLikes = likes - - // Convert data to DataPoints using site timezone - self.dataPoints = convertToDataPoints(from: details.data) - - self.isLoading = false + withAnimation(.spring) { + self.details = details + self.postLikes = likes + self.dataPoints = convertToDataPoints(from: details.data) + self.isLoading = false + } } catch { - self.error = error - self.isLoading = false + withAnimation(.spring) { + self.error = error + self.isLoading = false + } } } @@ -748,6 +749,7 @@ private struct MonthCellExpanded: View { post: .init( title: "Matter Smart Home Protocol Still Doesn't Matter: A Year Later", postID: "12345", + postURL: URL(string: "example.com"), date: .now, type: "post", author: nil, From c2ad07766dbb9c42096cdf55db08f3f16d0ce28d Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Thu, 24 Jul 2025 08:16:39 -0400 Subject: [PATCH 035/349] Add error handling --- .../Screens/PostStatsDetailsView.swift | 126 ++++++++---------- 1 file changed, 57 insertions(+), 69 deletions(-) diff --git a/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift b/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift index 4395b6ae652a..9216aadbef04 100644 --- a/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift +++ b/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift @@ -36,13 +36,8 @@ struct PostStatsDetailsView: View { @ViewBuilder private var contents: some View { - PostHeaderCard( - post: post, - details: details, - postLikes: postLikes, - isLoading: isLoading - ) - .cardStyle() + headerView + .cardStyle() // Views Over Time Chart if !dataPoints.isEmpty { @@ -76,71 +71,10 @@ struct PostStatsDetailsView: View { ) .cardStyle() } - } else if let error { - SimpleErrorView(error: error) - .frame(maxWidth: .infinity, minHeight: 200) - .cardStyle() } } - private func loadPostDetails() async { - guard let postId = post.postID, let postIdInt = Int(postId) else { - self.error = URLError(.unknown, userInfo: [NSLocalizedDescriptionKey: Strings.Errors.generic]) - self.isLoading = false - return - } - - async let detailsTask = context.service.getPostDetails(for: postIdInt) - async let likesTask: PostLikesData? = { - try? await context.service.getPostLikes(for: postIdInt, count: 20) - }() - - do { - let (details, likes) = try await (detailsTask, likesTask) - withAnimation(.spring) { - self.details = details - self.postLikes = likes - self.dataPoints = convertToDataPoints(from: details.data) - self.isLoading = false - } - } catch { - withAnimation(.spring) { - self.error = error - self.isLoading = false - } - } - } - - private func convertToDataPoints(from data: [StatsPostViews]) -> [DataPoint] { - // Convert DateComponents to Date using site timezone (similar to how StatsService does it) - var calendar = context.calendar - calendar.timeZone = context.timeZone - - // Convert StatsPostViews to DataPoints using the site timezone - return data.compactMap { postView in - guard let date = calendar.date(from: postView.date) else { return nil } - return DataPoint(date: date, value: postView.viewsCount) - } - } - - private var mockDataPoints: [DataPoint] { - ChartData.mock( - metric: .views, - granularity: initialDateRange.dateInterval.preferredGranularity, - range: initialDateRange - ).currentData - } -} - -private struct PostHeaderCard: View { - let post: TopListData.Post - let details: StatsPostDetails? - let postLikes: PostLikesData? - var isLoading: Bool - - @Environment(\.context) var context - - var body: some View { + private var headerView: some View { VStack(alignment: .leading, spacing: Constants.step2) { postDetailsView @@ -158,6 +92,10 @@ private struct PostHeaderCard: View { } else if isLoading { PostStatsMetricsStripView(metrics: .mock) .redacted(reason: .placeholder) + } else if let error { + SimpleErrorView(error: error) + .frame(minHeight: 200) + } } .frame(maxWidth: .infinity, alignment: .leading) @@ -190,6 +128,8 @@ private struct PostHeaderCard: View { } } + // MARK: - Data + private var metrics: SiteMetricsSet? { guard let details else { return nil @@ -208,6 +148,54 @@ private struct PostHeaderCard: View { formatter.timeZone = context.timeZone return formatter.string(from: date) } + + private func loadPostDetails() async { + guard let postId = post.postID, let postIdInt = Int(postId) else { + self.error = URLError(.unknown, userInfo: [NSLocalizedDescriptionKey: Strings.Errors.generic]) + self.isLoading = false + return + } + + async let detailsTask = context.service.getPostDetails(for: postIdInt) + async let likesTask: PostLikesData? = { + try? await context.service.getPostLikes(for: postIdInt, count: 20) + }() + + do { + let (details, likes) = try await (detailsTask, likesTask) + withAnimation(.spring) { + self.details = details + self.postLikes = likes + self.dataPoints = convertToDataPoints(from: details.data) + self.isLoading = false + } + } catch { + withAnimation(.spring) { + self.error = error + self.isLoading = false + } + } + } + + private func convertToDataPoints(from data: [StatsPostViews]) -> [DataPoint] { + // Convert DateComponents to Date using site timezone (similar to how StatsService does it) + var calendar = context.calendar + calendar.timeZone = context.timeZone + + // Convert StatsPostViews to DataPoints using the site timezone + return data.compactMap { postView in + guard let date = calendar.date(from: postView.date) else { return nil } + return DataPoint(date: date, value: postView.viewsCount) + } + } + + private var mockDataPoints: [DataPoint] { + ChartData.mock( + metric: .views, + granularity: initialDateRange.dateInterval.preferredGranularity, + range: initialDateRange + ).currentData + } } private struct PostStatsMetricsStripView: View { From 000afabff91130b0a3a5526732ac35d8df555ea4 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Thu, 24 Jul 2025 08:28:03 -0400 Subject: [PATCH 036/349] Handle hourly data not available scenario --- .../Cards/StandaloneChartCard.swift | 56 +++++++++++++++---- .../Screens/PostStatsDetailsView.swift | 15 ++++- .../Utilities/DateRangeGranularity.swift | 2 +- 3 files changed, 58 insertions(+), 15 deletions(-) diff --git a/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift b/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift index 5d749934b484..44ecc890a3a7 100644 --- a/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift +++ b/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift @@ -15,6 +15,8 @@ struct StandaloneChartCard: View { /// The metric type being displayed (e.g., views, likes, comments) let metric: SiteMetric + private let configuration: Configuration + @State private var dateRange: StatsDateRange @State private var selectedChartType: ChartType = .line @State private var isShowingDatePicker = false @@ -26,15 +28,25 @@ struct StandaloneChartCard: View { @Environment(\.redactionReasons) private var redactionReasons + struct Configuration { + var minimumGranularity: DateRangeGranularity = .hour + } + /// Creates a new standalone chart card. /// - Parameters: /// - dataPoints: The array of data points to display /// - metric: The metric type for proper formatting and colors /// - initialDateRange: The initial date range to display - init(dataPoints: [DataPoint], metric: SiteMetric, initialDateRange: StatsDateRange) { + init( + dataPoints: [DataPoint], + metric: SiteMetric, + initialDateRange: StatsDateRange, + configuration: Configuration = .init() + ) { self.dataPoints = dataPoints self.metric = metric self._dateRange = State(initialValue: initialDateRange) + self.configuration = configuration } var body: some View { @@ -56,11 +68,13 @@ struct StandaloneChartCard: View { // Chart content Group { - if let chartData { + if dateRange.dateInterval.preferredGranularity < configuration.minimumGranularity { + loadingErrorView(with: Strings.Chart.hourlyDataUnavailable) + } else if let chartData { chartContent(chartData: chartData) .opacity(redactionReasons.contains(.placeholder) ? 0.2 : 1.0) } else { - chartContent(chartData: .mock(metric: .views, granularity: .day, range: dateRange)) + chartContent(chartData: mockData) .redacted(reason: .placeholder) .opacity(0.33) } @@ -82,8 +96,6 @@ struct StandaloneChartCard: View { } } - // MARK: - Chart Content - @ViewBuilder private func chartContent(chartData: ChartData) -> some View { switch selectedChartType { @@ -93,7 +105,19 @@ struct StandaloneChartCard: View { BarChartView(data: chartData) } } - + + private func loadingErrorView(with message: String) -> some View { + chartContent(chartData: mockData) + .redacted(reason: .placeholder) + .grayscale(1) + .opacity(0.1) + .overlay { + SimpleErrorView(message: message) + } + } + + // MARK: – + private var trend: TrendViewModel { guard let chartData = chartData else { return TrendViewModel(currentValue: 0, previousValue: 0, metric: metric) @@ -110,12 +134,17 @@ struct StandaloneChartCard: View { dataPoints: dataPoints, dateRange: dateRange, metric: metric, - context: context + calendar: context.calendar, + granularity: max(dateRange.dateInterval.preferredGranularity, configuration.minimumGranularity) ) guard !Task.isCancelled else { return } self.chartData = chartData } + private var mockData: ChartData { + ChartData.mock(metric: .views, granularity: .day, range: dateRange) + } + // MARK: - Controls private var moreMenu: some View { @@ -186,9 +215,14 @@ struct StandaloneChartCard: View { } } -private func generateChartData(dataPoints: [DataPoint], dateRange: StatsDateRange, metric: SiteMetric, context: StatsContext) async -> ChartData { - let granularity = dateRange.dateInterval.preferredGranularity - let aggregator = StatsDataAggregator(calendar: context.calendar) +private func generateChartData( + dataPoints: [DataPoint], + dateRange: StatsDateRange, + metric: SiteMetric, + calendar: Calendar, + granularity: DateRangeGranularity +) async -> ChartData { + let aggregator = StatsDataAggregator(calendar: calendar) // Filter data points for current period let currentDataPoints = dataPoints.filter { dataPoint in @@ -224,7 +258,7 @@ private func generateChartData(dataPoints: [DataPoint], dateRange: StatsDateRang from: previousDateInterval, to: dateRange.dateInterval, component: dateRange.component, - calendar: context.calendar + calendar: calendar ) return ChartData( diff --git a/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift b/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift index 9216aadbef04..bd1a5ace223a 100644 --- a/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift +++ b/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift @@ -41,11 +41,10 @@ struct PostStatsDetailsView: View { // Views Over Time Chart if !dataPoints.isEmpty { - StandaloneChartCard(dataPoints: dataPoints, metric: .views, initialDateRange: initialDateRange) + makeChartView(dataPoints: dataPoints) .cardStyle() } else if isLoading { - StandaloneChartCard(dataPoints: mockDataPoints, metric: .views, initialDateRange: initialDateRange) - .cardStyle() + makeChartView(dataPoints: mockDataPoints) .redacted(reason: .placeholder) } @@ -74,6 +73,16 @@ struct PostStatsDetailsView: View { } } + private func makeChartView(dataPoints: [DataPoint]) -> some View { + StandaloneChartCard( + dataPoints: dataPoints, + metric: .views, + initialDateRange: initialDateRange, + configuration: .init(minimumGranularity: .day) + ) + .cardStyle() + } + private var headerView: some View { VStack(alignment: .leading, spacing: Constants.step2) { postDetailsView diff --git a/Modules/Sources/JetpackStats/Utilities/DateRangeGranularity.swift b/Modules/Sources/JetpackStats/Utilities/DateRangeGranularity.swift index a71591e76f24..d2d7cd051975 100644 --- a/Modules/Sources/JetpackStats/Utilities/DateRangeGranularity.swift +++ b/Modules/Sources/JetpackStats/Utilities/DateRangeGranularity.swift @@ -1,6 +1,6 @@ import Foundation -enum DateRangeGranularity { +enum DateRangeGranularity: Comparable { case hour case day case month From 20c98eec37c950297a5c7bc43f3c8411df982730 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Thu, 24 Jul 2025 08:29:03 -0400 Subject: [PATCH 037/349] Cleanup StandaloneChartCard --- .../Cards/StandaloneChartCard.swift | 33 ++++++++++--------- .../Screens/PostStatsDetailsView.swift | 1 - 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift b/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift index 44ecc890a3a7..062f7263124c 100644 --- a/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift +++ b/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift @@ -66,20 +66,7 @@ struct StandaloneChartCard: View { .padding(.top, 8) } - // Chart content - Group { - if dateRange.dateInterval.preferredGranularity < configuration.minimumGranularity { - loadingErrorView(with: Strings.Chart.hourlyDataUnavailable) - } else if let chartData { - chartContent(chartData: chartData) - .opacity(redactionReasons.contains(.placeholder) ? 0.2 : 1.0) - } else { - chartContent(chartData: mockData) - .redacted(reason: .placeholder) - .opacity(0.33) - } - } - .frame(height: chartHeight) + chartView // Date range controls dateRangeControls @@ -95,7 +82,23 @@ struct StandaloneChartCard: View { await refreshChartData() } } - + + private var chartView: some View { + Group { + if dateRange.dateInterval.preferredGranularity < configuration.minimumGranularity { + loadingErrorView(with: Strings.Chart.hourlyDataUnavailable) + } else if let chartData { + chartContent(chartData: chartData) + .opacity(redactionReasons.contains(.placeholder) ? 0.2 : 1.0) + } else { + chartContent(chartData: mockData) + .redacted(reason: .placeholder) + .opacity(0.33) + } + } + .frame(height: chartHeight) + } + @ViewBuilder private func chartContent(chartData: ChartData) -> some View { switch selectedChartType { diff --git a/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift b/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift index bd1a5ace223a..6574b46f95fc 100644 --- a/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift +++ b/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift @@ -42,7 +42,6 @@ struct PostStatsDetailsView: View { // Views Over Time Chart if !dataPoints.isEmpty { makeChartView(dataPoints: dataPoints) - .cardStyle() } else if isLoading { makeChartView(dataPoints: mockDataPoints) .redacted(reason: .placeholder) From a232ef7227f8d853c87b38b8325335d952426636 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Thu, 24 Jul 2025 08:55:44 -0400 Subject: [PATCH 038/349] Extract and improve WeeklyTrendsView --- Modules/Sources/JetpackStats/Constants.swift | 17 ++ .../Screens/PostStatsDetailsView.swift | 199 ++------------- .../JetpackStats/Views/WeeklyTrendsView.swift | 226 ++++++++++++++++++ 3 files changed, 263 insertions(+), 179 deletions(-) create mode 100644 Modules/Sources/JetpackStats/Views/WeeklyTrendsView.swift diff --git a/Modules/Sources/JetpackStats/Constants.swift b/Modules/Sources/JetpackStats/Constants.swift index 9355fc591c9c..f0b474896aef 100644 --- a/Modules/Sources/JetpackStats/Constants.swift +++ b/Modules/Sources/JetpackStats/Constants.swift @@ -41,6 +41,23 @@ enum Constants { static let step2: CGFloat = 18 static let step3: CGFloat = 24 static let step4: CGFloat = 32 + + static func heatmapColor(baseColor: Color, intensity: Double) -> Color { + if intensity == 0 { + return Color(UIColor.secondarySystemBackground) + } + + // Use graduated opacity based on intensity + if intensity < 0.25 { + return baseColor.opacity(0.2) + } else if intensity < 0.5 { + return baseColor.opacity(0.4) + } else if intensity < 0.75 { + return baseColor.opacity(0.6) + } else { + return baseColor.opacity(0.85) + } + } } private extension Color { diff --git a/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift b/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift index 6574b46f95fc..d634de1ab65d 100644 --- a/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift +++ b/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift @@ -34,30 +34,35 @@ struct PostStatsDetailsView: View { } } +#warning("TEMP") + @ViewBuilder private var contents: some View { headerView .cardStyle() - // Views Over Time Chart - if !dataPoints.isEmpty { - makeChartView(dataPoints: dataPoints) - } else if isLoading { - makeChartView(dataPoints: mockDataPoints) - .redacted(reason: .placeholder) - } +// // Views Over Time Chart +// if !dataPoints.isEmpty { +// makeChartView(dataPoints: dataPoints) +// } else if isLoading { +// makeChartView(dataPoints: mockDataPoints) +// .redacted(reason: .placeholder) +// } if let details { // Peak Performance Card - if details.highestMonth != nil || details.highestDayAverage != nil || details.highestWeekAverage != nil { - PeakPerformanceCard(details: details) - .cardStyle() - } +// if details.highestMonth != nil || details.highestDayAverage != nil || details.highestWeekAverage != nil { +// PeakPerformanceCard(details: details) +// .cardStyle() +// } // Weekly Trends Chart if !details.recentWeeks.isEmpty { - WeeklyTrendsCard(weeks: details.recentWeeks, context: context) - .cardStyle() + WeeklyTrendsView( + weeks: WeeklyTrendsView.Week.make(from: details.recentWeeks, using: context.calendar), + context: context + ) + .cardStyle() } // Yearly Summary @@ -395,144 +400,6 @@ private struct PeakPerformanceCard: View { } } -private struct WeeklyTrendsCard: View { - let weeks: [StatsWeeklyBreakdown] - let context: StatsContext - - private let cellSpacing: CGFloat = 4 - private let weekLabelWidth: CGFloat = 36 - private let dayLabels = ["M", "T", "W", "T", "F", "S", "S"] - - var body: some View { - VStack(alignment: .leading, spacing: Constants.step2) { - StatsCardTitleView(title: Strings.PostDetails.recentWeeks) - - VStack(alignment: .leading, spacing: cellSpacing) { - // Day labels header - HStack(spacing: 0) { - Color.clear - .frame(width: weekLabelWidth) - - HStack(spacing: cellSpacing) { - ForEach(dayLabels, id: \.self) { day in - Text(day) - .font(.caption2) - .foregroundColor(.secondary) - .frame(maxWidth: .infinity) - .multilineTextAlignment(.center) - } - } - } - - // Heatmap grid - VStack(spacing: cellSpacing) { - // Show last 8 weeks, 7 days per week - ForEach(Array(weeks.prefix(8).enumerated()), id: \.offset) { weekIndex, week in - HStack(spacing: 8) { - // Week label - Text(weekLabel(for: week)) - .font(.caption2) - .foregroundColor(.secondary) - .frame(width: weekLabelWidth, alignment: .trailing) - - HStack(spacing: cellSpacing) { - // Days in the week - ForEach(week.days, id: \.date) { day in - DayCell(viewsCount: day.viewsCount) - .frame(maxWidth: .infinity) - .aspectRatio(1, contentMode: .fit) - } - } - } - } - } - - // Legend - HStack(spacing: Constants.step1) { - Text(Strings.PostDetails.less) - .font(.caption2) - .foregroundColor(.secondary) - - HStack(spacing: 2) { - ForEach(0..<5) { level in - RoundedRectangle(cornerRadius: 4) - .fill(heatmapColor(for: Double(level) / 4.0)) - .frame(width: 12, height: 12) - } - } - - Text(Strings.PostDetails.more) - .font(.caption2) - .foregroundColor(.secondary) - } - .padding(.top, Constants.step1) - } - } - .padding(Constants.step2) - .frame(maxWidth: .infinity, alignment: .leading) - } - - private func weekLabel(for week: StatsWeeklyBreakdown) -> String { - let formatter = DateFormatter() - formatter.dateFormat = "MMM d" - formatter.timeZone = context.timeZone - - guard let startDate = context.calendar.date(from: week.startDay) else { return "" } - return formatter.string(from: startDate) - } - - private func heatmapColor(for intensity: Double) -> Color { - // Use ColorStudio blue gradient - if intensity < 0.25 { - return Constants.Colors.blue.opacity(0.2) - } else if intensity < 0.5 { - return Constants.Colors.blue.opacity(0.4) - } else if intensity < 0.75 { - return Constants.Colors.blue.opacity(0.6) - } else { - return Constants.Colors.blue.opacity(0.85) - } - } -} - -private struct DayCell: View { - let viewsCount: Int - - // Define max views for normalization (can be adjusted based on data) - private let maxViews = 200 - - private var intensity: Double { - min(1.0, Double(viewsCount) / Double(maxViews)) - } - - var body: some View { - RoundedRectangle(cornerRadius: 4) - .fill(heatmapColor) - .overlay( - Text("\(viewsCount)") - .font(.caption2) - .foregroundColor(intensity > 0.6 ? .white : .primary) - .opacity(intensity > 0.3 ? 1 : 0) - ) - } - - private var heatmapColor: Color { - if viewsCount == 0 { - return Color(UIColor.secondarySystemBackground) - } - - // Use ColorStudio blue gradient - if intensity < 0.25 { - return Constants.Colors.blue.opacity(0.2) - } else if intensity < 0.5 { - return Constants.Colors.blue.opacity(0.4) - } else if intensity < 0.75 { - return Constants.Colors.blue.opacity(0.6) - } else { - return Constants.Colors.blue.opacity(0.85) - } - } -} private struct YearlySummaryCard: View { let yearlyTotals: [Int: Int] @@ -612,20 +479,7 @@ private struct YearlySummaryCard: View { } private func heatmapColor(for intensity: Double) -> Color { - if intensity == 0 { - return Color(.secondarySystemBackground) - } - - // Use ColorStudio blue gradient - if intensity < 0.25 { - return Constants.Colors.blue.opacity(0.2) - } else if intensity < 0.5 { - return Constants.Colors.blue.opacity(0.4) - } else if intensity < 0.75 { - return Constants.Colors.blue.opacity(0.6) - } else { - return Constants.Colors.blue.opacity(0.85) - } + Constants.heatmapColor(baseColor: Constants.Colors.blue, intensity: intensity) } } @@ -722,20 +576,7 @@ private struct MonthCellExpanded: View { } private var heatmapColor: Color { - if viewsCount == 0 { - return Color(UIColor.secondarySystemBackground) - } - - // Use ColorStudio blue gradient - if intensity < 0.25 { - return Constants.Colors.blue.opacity(0.2) - } else if intensity < 0.5 { - return Constants.Colors.blue.opacity(0.4) - } else if intensity < 0.75 { - return Constants.Colors.blue.opacity(0.6) - } else { - return Constants.Colors.blue.opacity(0.85) - } + Constants.heatmapColor(baseColor: Constants.Colors.blue, intensity: intensity) } } diff --git a/Modules/Sources/JetpackStats/Views/WeeklyTrendsView.swift b/Modules/Sources/JetpackStats/Views/WeeklyTrendsView.swift new file mode 100644 index 000000000000..616e7a6862c7 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/WeeklyTrendsView.swift @@ -0,0 +1,226 @@ +import SwiftUI +import WordPressKit + +struct WeeklyTrendsView: View { + let weeks: [Week] + let context: StatsContext + + private let cellSpacing: CGFloat = 4 + private let weekLabelWidth: CGFloat = 36 + private let dayLabels = ["M", "T", "W", "T", "F", "S", "S"] + + struct Week { + struct Day { + let date: Date + let viewsCount: Int + } + + let startDate: Date + let days: [Day] + + static func make(from breakdown: StatsWeeklyBreakdown, using calendar: Calendar) -> Week? { + guard let startDate = calendar.date(from: breakdown.startDay) else { return nil } + + let days = breakdown.days.compactMap { day -> Day? in + guard let date = calendar.date(from: day.date) else { return nil } + return Day(date: date, viewsCount: day.viewsCount) + } + + return Week(startDate: startDate, days: days) + } + + static func make(from breakdowns: [StatsWeeklyBreakdown], using calendar: Calendar) -> [Week] { + breakdowns.compactMap { make(from: $0, using: calendar) } + } + } + + var body: some View { + VStack(alignment: .leading, spacing: Constants.step2) { + StatsCardTitleView(title: Strings.PostDetails.recentWeeks) + + VStack(alignment: .leading, spacing: cellSpacing) { + // Day labels header + HStack(spacing: 0) { + Color.clear + .frame(width: weekLabelWidth) + + HStack(spacing: cellSpacing) { + ForEach(dayLabels, id: \.self) { day in + Text(day) + .font(.caption2) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + } + } + } + + // Heatmap grid + VStack(spacing: cellSpacing) { + // Show last 8 weeks, 7 days per week + ForEach(Array(weeks.prefix(8).enumerated()), id: \.offset) { weekIndex, week in + HStack(spacing: 8) { + // Week label + Text(weekLabel(for: week)) + .font(.caption2) + .foregroundColor(.secondary) + .frame(width: weekLabelWidth, alignment: .trailing) + + HStack(spacing: cellSpacing) { + // Days in the week + ForEach(week.days, id: \.date) { day in + DayCell(viewsCount: day.viewsCount) + .frame(maxWidth: .infinity) + .aspectRatio(1, contentMode: .fit) + } + } + } + } + } + + // Legend + HStack(spacing: Constants.step1) { + Text(Strings.PostDetails.less) + .font(.caption2) + .foregroundColor(.secondary) + + HStack(spacing: 2) { + ForEach(0..<5) { level in + RoundedRectangle(cornerRadius: 4) + .fill(heatmapColor(for: Double(level) / 4.0)) + .frame(width: 12, height: 12) + } + } + + Text(Strings.PostDetails.more) + .font(.caption2) + .foregroundColor(.secondary) + } + .padding(.top, Constants.step1) + } + } + .padding(Constants.step2) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func weekLabel(for week: Week) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "MMM d" + formatter.timeZone = context.timeZone + + return formatter.string(from: week.startDate) + } + + private func heatmapColor(for intensity: Double) -> Color { + Constants.heatmapColor(baseColor: Constants.Colors.blue, intensity: intensity) + } +} + +private struct DayCell: View { + let viewsCount: Int + + // Define max views for normalization (can be adjusted based on data) + private let maxViews = 200 + + private var intensity: Double { + min(1.0, Double(viewsCount) / Double(maxViews)) + } + + var body: some View { + RoundedRectangle(cornerRadius: 4) + .fill(heatmapColor) + .overlay( + Text("\(viewsCount)") + .font(.caption2) + .foregroundColor(intensity > 0.6 ? .white : .primary) + .opacity(intensity > 0.3 ? 1 : 0) + ) + } + + private var heatmapColor: Color { + Constants.heatmapColor(baseColor: Constants.Colors.blue, intensity: intensity) + } +} + +// MARK: - Mock Data + +extension WeeklyTrendsView.Week { + static func mockWeeks(count: Int = 8) -> [WeeklyTrendsView.Week] { + let calendar = Calendar.current + let today = Date() + + return (0.. Date: Thu, 24 Jul 2025 09:37:57 -0400 Subject: [PATCH 039/349] Improve WeeklyTrendsView --- .../Cards/RealtimeMetricsCard.swift | 2 +- Modules/Sources/JetpackStats/Constants.swift | 18 +- .../Screens/ChartDataListView.swift | 2 +- .../Screens/PostStatsDetailsView.swift | 16 +- .../JetpackStats/Screens/StatsMainView.swift | 2 +- .../JetpackStats/Screens/TrafficTabView.swift | 2 +- .../Views/CustomDateRangePicker.swift | 2 +- .../Views/MetricsOverviewTabView.swift | 2 +- .../JetpackStats/Views/WeeklyTrendsView.swift | 191 +++++++++++------- 9 files changed, 140 insertions(+), 97 deletions(-) diff --git a/Modules/Sources/JetpackStats/Cards/RealtimeMetricsCard.swift b/Modules/Sources/JetpackStats/Cards/RealtimeMetricsCard.swift index aa85e09c8cc2..b4e4d35fe3b2 100644 --- a/Modules/Sources/JetpackStats/Cards/RealtimeMetricsCard.swift +++ b/Modules/Sources/JetpackStats/Cards/RealtimeMetricsCard.swift @@ -115,5 +115,5 @@ struct RealtimeMetricsCard: View { #Preview { RealtimeMetricsCard() .padding() - .background(Constants.Colors.statsBackground) + .background(Constants.Colors.background) } diff --git a/Modules/Sources/JetpackStats/Constants.swift b/Modules/Sources/JetpackStats/Constants.swift index f0b474896aef..7b727c18999c 100644 --- a/Modules/Sources/JetpackStats/Constants.swift +++ b/Modules/Sources/JetpackStats/Constants.swift @@ -23,7 +23,7 @@ enum Constants { dark: UIColor(red: 0.3, green: 0.15, blue: 0.15, alpha: 1.0) )) - static let statsBackground = Color(UIColor( + static let background = Color(UIColor( light: CSColor.Gray.shade(.shade0), dark: UIColor.systemBackground )) @@ -46,16 +46,16 @@ enum Constants { if intensity == 0 { return Color(UIColor.secondarySystemBackground) } - + // Use graduated opacity based on intensity - if intensity < 0.25 { - return baseColor.opacity(0.2) - } else if intensity < 0.5 { - return baseColor.opacity(0.4) - } else if intensity < 0.75 { - return baseColor.opacity(0.6) + if intensity <= 0.25 { + return baseColor.opacity(0.07) + } else if intensity <= 0.5 { + return baseColor.opacity(0.14) + } else if intensity <= 0.75 { + return baseColor.opacity(0.25) } else { - return baseColor.opacity(0.85) + return baseColor.opacity(0.38) } } } diff --git a/Modules/Sources/JetpackStats/Screens/ChartDataListView.swift b/Modules/Sources/JetpackStats/Screens/ChartDataListView.swift index 3dd1e15debd2..3d65d1c29975 100644 --- a/Modules/Sources/JetpackStats/Screens/ChartDataListView.swift +++ b/Modules/Sources/JetpackStats/Screens/ChartDataListView.swift @@ -29,7 +29,7 @@ struct ChartDataListView: View { } } } - .background(Constants.Colors.statsBackground) + .background(Constants.Colors.background) .navigationTitle("Chart Data") .navigationBarTitleDisplayMode(.inline) .toolbar { diff --git a/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift b/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift index d634de1ab65d..f4c0a91ef5f9 100644 --- a/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift +++ b/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift @@ -26,7 +26,7 @@ struct PostStatsDetailsView: View { } .padding(.vertical, Constants.step1) } - .background(Constants.Colors.statsBackground) + .background(Constants.Colors.background) .navigationTitle(Strings.PostDetails.title) .navigationBarTitleDisplayMode(.inline) .task { @@ -58,10 +58,16 @@ struct PostStatsDetailsView: View { // Weekly Trends Chart if !details.recentWeeks.isEmpty { - WeeklyTrendsView( - weeks: WeeklyTrendsView.Week.make(from: details.recentWeeks, using: context.calendar), - context: context - ) + VStack(alignment: .leading, spacing: Constants.step2) { + StatsCardTitleView(title: Strings.PostDetails.recentWeeks) + + WeeklyTrendsView( + weeks: WeeklyTrendsView.Week.make(from: details.recentWeeks, using: context.calendar), + calendar: context.calendar, + timeZone: context.timeZone + ) + } + .padding(Constants.step2) .cardStyle() } diff --git a/Modules/Sources/JetpackStats/Screens/StatsMainView.swift b/Modules/Sources/JetpackStats/Screens/StatsMainView.swift index 2f6364cfcf7e..4b5845713f8e 100644 --- a/Modules/Sources/JetpackStats/Screens/StatsMainView.swift +++ b/Modules/Sources/JetpackStats/Screens/StatsMainView.swift @@ -18,7 +18,7 @@ public struct StatsMainView: View { .safeAreaInset(edge: .top) { StatsTabBar(selectedTab: $selectedTab, showBackground: isTabBarBackgroundShown) } - .background(Constants.Colors.statsBackground) + .background(Constants.Colors.background) .navigationTitle(Strings.stats) .navigationBarTitleDisplayMode(.inline) .environment(\.context, context) diff --git a/Modules/Sources/JetpackStats/Screens/TrafficTabView.swift b/Modules/Sources/JetpackStats/Screens/TrafficTabView.swift index ab98b2841f18..b88b244b0def 100644 --- a/Modules/Sources/JetpackStats/Screens/TrafficTabView.swift +++ b/Modules/Sources/JetpackStats/Screens/TrafficTabView.swift @@ -28,7 +28,7 @@ struct TrafficTabView: View { viewModel.dateRange = $0 } } - .background(Constants.Colors.statsBackground) + .background(Constants.Colors.background) .toolbar { if #available(iOS 26, *) { normalModeToolbarContent diff --git a/Modules/Sources/JetpackStats/Views/CustomDateRangePicker.swift b/Modules/Sources/JetpackStats/Views/CustomDateRangePicker.swift index e7948d2532da..89e293e5aa27 100644 --- a/Modules/Sources/JetpackStats/Views/CustomDateRangePicker.swift +++ b/Modules/Sources/JetpackStats/Views/CustomDateRangePicker.swift @@ -29,7 +29,7 @@ struct CustomDateRangePicker: View { ScrollView { contents } - .background(Constants.Colors.statsBackground) + .background(Constants.Colors.background) .navigationTitle(Strings.DatePicker.customRange) .navigationBarTitleDisplayMode(.inline) .toolbar { diff --git a/Modules/Sources/JetpackStats/Views/MetricsOverviewTabView.swift b/Modules/Sources/JetpackStats/Views/MetricsOverviewTabView.swift index 581ddf7103dc..e1eccae16656 100644 --- a/Modules/Sources/JetpackStats/Views/MetricsOverviewTabView.swift +++ b/Modules/Sources/JetpackStats/Views/MetricsOverviewTabView.swift @@ -152,7 +152,7 @@ private struct MetricItemView: View { .background(Color(.systemBackground)) .cardStyle() .frame(maxHeight: .infinity, alignment: .center) - .background(Constants.Colors.statsBackground) + .background(Constants.Colors.background) } #endif diff --git a/Modules/Sources/JetpackStats/Views/WeeklyTrendsView.swift b/Modules/Sources/JetpackStats/Views/WeeklyTrendsView.swift index 616e7a6862c7..9e73f15276de 100644 --- a/Modules/Sources/JetpackStats/Views/WeeklyTrendsView.swift +++ b/Modules/Sources/JetpackStats/Views/WeeklyTrendsView.swift @@ -3,16 +3,36 @@ import WordPressKit struct WeeklyTrendsView: View { let weeks: [Week] - let context: StatsContext + let calendar: Calendar + let timeZone: TimeZone + let metric: SiteMetric = .views private let cellSpacing: CGFloat = 4 private let weekLabelWidth: CGFloat = 36 - private let dayLabels = ["M", "T", "W", "T", "F", "S", "S"] + + private var maxValue: Int { + weeks.flatMap { $0.days }.map { $0.value }.max() ?? 1 + } + + private var dayLabels: [String] { + // Get localized very short weekday symbols from Calendar + let formatter = DateFormatter() + formatter.calendar = calendar + formatter.locale = calendar.locale ?? Locale.current + + // Get weekday symbols in the order defined by the calendar's firstWeekday + let symbols = formatter.veryShortWeekdaySymbols ?? [] + let firstWeekday = calendar.firstWeekday + + // Reorder symbols to start with the calendar's first weekday + let reorderedSymbols = Array(symbols[(firstWeekday - 1)...]) + Array(symbols[..<(firstWeekday - 1)]) + return reorderedSymbols + } struct Week { struct Day { let date: Date - let viewsCount: Int + let value: Int } let startDate: Date @@ -23,7 +43,7 @@ struct WeeklyTrendsView: View { let days = breakdown.days.compactMap { day -> Day? in guard let date = calendar.date(from: day.date) else { return nil } - return Day(date: date, viewsCount: day.viewsCount) + return Day(date: date, value: day.viewsCount) } return Week(startDate: startDate, days: days) @@ -35,10 +55,7 @@ struct WeeklyTrendsView: View { } var body: some View { - VStack(alignment: .leading, spacing: Constants.step2) { - StatsCardTitleView(title: Strings.PostDetails.recentWeeks) - - VStack(alignment: .leading, spacing: cellSpacing) { + VStack(alignment: .leading, spacing: cellSpacing) { // Day labels header HStack(spacing: 0) { Color.clear @@ -57,8 +74,8 @@ struct WeeklyTrendsView: View { // Heatmap grid VStack(spacing: cellSpacing) { - // Show last 8 weeks, 7 days per week - ForEach(Array(weeks.prefix(8).enumerated()), id: \.offset) { weekIndex, week in + // Show last 4 weeks, 7 days per week + ForEach(Array(weeks.prefix(4).enumerated()), id: \.offset) { weekIndex, week in HStack(spacing: 8) { // Week label Text(weekLabel(for: week)) @@ -69,9 +86,13 @@ struct WeeklyTrendsView: View { HStack(spacing: cellSpacing) { // Days in the week ForEach(week.days, id: \.date) { day in - DayCell(viewsCount: day.viewsCount) - .frame(maxWidth: .infinity) - .aspectRatio(1, contentMode: .fit) + DayCell( + value: day.value, + maxValue: maxValue, + metric: metric + ) + .frame(maxWidth: .infinity) + .aspectRatio(1, contentMode: .fit) } } } @@ -79,66 +100,75 @@ struct WeeklyTrendsView: View { } // Legend - HStack(spacing: Constants.step1) { - Text(Strings.PostDetails.less) - .font(.caption2) - .foregroundColor(.secondary) - - HStack(spacing: 2) { - ForEach(0..<5) { level in - RoundedRectangle(cornerRadius: 4) - .fill(heatmapColor(for: Double(level) / 4.0)) - .frame(width: 12, height: 12) - } - } - - Text(Strings.PostDetails.more) - .font(.caption2) - .foregroundColor(.secondary) - } + legend .padding(.top, Constants.step1) - } } - .padding(Constants.step2) .frame(maxWidth: .infinity, alignment: .leading) } - + + private var legend: some View { + HStack(spacing: Constants.step1) { + Text(Strings.PostDetails.less) + .font(.caption2) + .foregroundColor(.secondary) + + HStack(spacing: 4) { + ForEach(0..<5) { level in + RoundedRectangle(cornerRadius: 4) + .fill(Constants.heatmapColor(baseColor: metric.primaryColor, intensity: Double(level) / 4.0)) + .frame(width: 16, height: 16) + } + } + + Text(Strings.PostDetails.more) + .font(.caption2) + .foregroundColor(.secondary) + } + } + private func weekLabel(for week: Week) -> String { let formatter = DateFormatter() formatter.dateFormat = "MMM d" - formatter.timeZone = context.timeZone - + formatter.timeZone = timeZone return formatter.string(from: week.startDate) } private func heatmapColor(for intensity: Double) -> Color { - Constants.heatmapColor(baseColor: Constants.Colors.blue, intensity: intensity) + Constants.heatmapColor(baseColor: metric.primaryColor, intensity: intensity) } } private struct DayCell: View { - let viewsCount: Int - - // Define max views for normalization (can be adjusted based on data) - private let maxViews = 200 + let value: Int + let maxValue: Int + let metric: SiteMetric private var intensity: Double { - min(1.0, Double(viewsCount) / Double(maxViews)) + guard maxValue > 0 else { + return 0 + } + return min(1.0, Double(value) / Double(maxValue)) } var body: some View { RoundedRectangle(cornerRadius: 4) .fill(heatmapColor) - .overlay( - Text("\(viewsCount)") - .font(.caption2) - .foregroundColor(intensity > 0.6 ? .white : .primary) - .opacity(intensity > 0.3 ? 1 : 0) - ) + .overlay { + if value > 0 { + Text(formattedValue) + .font(.caption.weight(.medium)) + .foregroundColor(.primary) + .foregroundColor(intensity > 0.75 ? Color(.systemBackground) : .primary) + } + } } - + + private var formattedValue: String { + StatsValueFormatter(metric: metric).format(value: value, context: .compact) + } + private var heatmapColor: Color { - Constants.heatmapColor(baseColor: Constants.Colors.blue, intensity: intensity) + Constants.heatmapColor(baseColor: metric.primaryColor, intensity: intensity) } } @@ -158,12 +188,15 @@ extension WeeklyTrendsView.Week { // Generate realistic view counts with patterns let baseViews = Int.random(in: 20...150) - let weekendMultiplier = (dayOffset == 5 || dayOffset == 6) ? 0.7 : 1.0 - let randomVariation = Double.random(in: 0.8...1.2) + // Determine if this is a weekend based on the calendar + let isWeekend = calendar.isDateInWeekend(date) + let weekendMultiplier = isWeekend ? 0.7 : 1.0 + + let randomVariation = Double.random(in: 0.8...1.2) let viewsCount = Int(Double(baseViews) * weekendMultiplier * randomVariation) - return WeeklyTrendsView.Week.Day(date: date, viewsCount: max(0, viewsCount)) + return WeeklyTrendsView.Week.Day(date: date, value: max(0, viewsCount)) } return WeeklyTrendsView.Week(startDate: startOfWeek, days: days) @@ -177,7 +210,7 @@ extension WeeklyTrendsView.Week { days: week.days.map { day in WeeklyTrendsView.Week.Day( date: day.date, - viewsCount: Int.random(in: 150...250) + value: Int.random(in: 150...250) ) } ) @@ -189,7 +222,7 @@ extension WeeklyTrendsView.Week { WeeklyTrendsView.Week( startDate: week.startDate, days: week.days.map { day in - WeeklyTrendsView.Week.Day(date: day.date, viewsCount: 0) + WeeklyTrendsView.Week.Day(date: day.date, value: 0) } ) } @@ -198,29 +231,33 @@ extension WeeklyTrendsView.Week { // MARK: - Previews -#Preview("Default") { - WeeklyTrendsView( - weeks: WeeklyTrendsView.Week.mockWeeks(), - context: StatsContext.demo - ) - .cardStyle() - .padding() -} +#Preview { + ScrollView { + VStack(spacing: Constants.step2) { + WeeklyTrendsView( + weeks: WeeklyTrendsView.Week.mockWeeks(count: 4), + calendar: StatsContext.demo.calendar, + timeZone: StatsContext.demo.timeZone + ) + .padding(Constants.step2) + .cardStyle() -#Preview("High Traffic") { - WeeklyTrendsView( - weeks: WeeklyTrendsView.Week.mockHighTraffic, - context: StatsContext.demo - ) - .cardStyle() - .padding() -} + WeeklyTrendsView( + weeks: Array(WeeklyTrendsView.Week.mockHighTraffic.prefix(4)), + calendar: StatsContext.demo.calendar, + timeZone: StatsContext.demo.timeZone + ) + .padding(Constants.step2) + .cardStyle() -#Preview("Empty State") { - WeeklyTrendsView( - weeks: WeeklyTrendsView.Week.mockEmpty, - context: StatsContext.demo - ) - .cardStyle() - .padding() + WeeklyTrendsView( + weeks: Array(WeeklyTrendsView.Week.mockEmpty.prefix(4)), + calendar: StatsContext.demo.calendar, + timeZone: StatsContext.demo.timeZone + ) + .padding(Constants.step2) + .cardStyle() + } + } + .background(Constants.Colors.background) } From b4424d3da166c9ba861aa21ccbfa739e41111eae Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Thu, 24 Jul 2025 10:58:33 -0400 Subject: [PATCH 040/349] Add tooltip --- .../Screens/PostStatsDetailsView.swift | 197 +-------- Modules/Sources/JetpackStats/Strings.swift | 24 ++ .../PopoverPresentationModifier.swift | 11 + .../Views/CustomDateRangePicker.swift | 10 - .../JetpackStats/Views/HeatmapView.swift | 103 +++++ .../JetpackStats/Views/WeeklyTrendsView.swift | 376 +++++++++++++----- .../JetpackStats/Views/YearlyTrendsView.swift | 292 ++++++++++++++ 7 files changed, 714 insertions(+), 299 deletions(-) create mode 100644 Modules/Sources/JetpackStats/Utilities/Modifiers/PopoverPresentationModifier.swift create mode 100644 Modules/Sources/JetpackStats/Views/HeatmapView.swift create mode 100644 Modules/Sources/JetpackStats/Views/YearlyTrendsView.swift diff --git a/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift b/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift index f4c0a91ef5f9..a25f98660419 100644 --- a/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift +++ b/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift @@ -72,12 +72,17 @@ struct PostStatsDetailsView: View { } // Yearly Summary - if !details.yearlyTotals.isEmpty { - YearlySummaryCard( - yearlyTotals: details.yearlyTotals, - overallAverages: details.overallAverages, - monthlyBreakdown: details.monthlyBreakdown - ) + if !dataPoints.isEmpty { + VStack(alignment: .leading, spacing: Constants.step2) { + StatsCardTitleView(title: Strings.PostDetails.monthlyActivity) + + YearlyTrendsView( + dataPoints: dataPoints, + calendar: context.calendar, + timeZone: context.timeZone + ) + } + .padding(Constants.step2) .cardStyle() } } @@ -406,186 +411,6 @@ private struct PeakPerformanceCard: View { } } - -private struct YearlySummaryCard: View { - let yearlyTotals: [Int: Int] - let overallAverages: [Int: Int] - let monthlyBreakdown: [StatsPostViews] - - private let cellSpacing: CGFloat = 6 - private let monthNames = [ - ["Jan", "Feb", "Mar", "Apr", "May", "Jun"], - ["Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] - ] - - var body: some View { - VStack(alignment: .leading, spacing: Constants.step2) { - StatsCardTitleView(title: Strings.PostDetails.monthlyActivity) - - // Year sections - let sortedYears = yearlyTotals.keys.sorted(by: >).prefix(3) - let maxMonthlyViews = monthlyBreakdown.map(\.viewsCount).max() ?? 5000 - - VStack(spacing: Constants.step3) { - ForEach(sortedYears, id: \.self) { year in - YearSection( - year: year, - monthlyData: getMonthlyData(for: year), - maxViews: maxMonthlyViews, - yearTotal: yearlyTotals[year] ?? 0, - previousYearTotal: yearlyTotals[year - 1] - ) - - if year != sortedYears.last { - Divider() - } - } - } - - // Legend - HStack(spacing: Constants.step2) { - HStack(spacing: Constants.step1) { - Text(Strings.PostDetails.less) - .font(.caption2) - .foregroundColor(.secondary) - - HStack(spacing: 3) { - ForEach(0..<5) { level in - RoundedRectangle(cornerRadius: 4) - .fill(heatmapColor(for: Double(level) / 4.0)) - .frame(width: 16, height: 16) - } - } - - Text(Strings.PostDetails.more) - .font(.caption2) - .foregroundColor(.secondary) - } - - Spacer() - } - .padding(.top, Constants.step1) - } - .padding(Constants.step2) - .frame(maxWidth: .infinity, alignment: .leading) - } - - private func getMonthlyData(for year: Int) -> [Int] { - var monthlyViews = Array(repeating: 0, count: 12) - - for postView in monthlyBreakdown { - if postView.date.year == year, - let month = postView.date.month, - month >= 1 && month <= 12 { - monthlyViews[month - 1] = postView.viewsCount - } - } - - return monthlyViews - } - - private func heatmapColor(for intensity: Double) -> Color { - Constants.heatmapColor(baseColor: Constants.Colors.blue, intensity: intensity) - } -} - -private struct YearSection: View { - let year: Int - let monthlyData: [Int] - let maxViews: Int - let yearTotal: Int - let previousYearTotal: Int? - - private let cellSpacing: CGFloat = 6 - - var body: some View { - VStack(alignment: .leading, spacing: Constants.step1) { - // Year header with total and growth - HStack(alignment: .center) { - Text(String(year)) - .font(.headline) - - Spacer() - - Text(StatsValueFormatter.formatNumber(yearTotal)) - .font(.headline) - .foregroundColor(.secondary) - .monospacedDigit() - - - if let previousTotal = previousYearTotal, previousTotal > 0 { - BadgeTrendIndicator( - trend: TrendViewModel( - currentValue: yearTotal, - previousValue: previousTotal, - metric: .views - ) - ) - } - } - - // Two-column grid - VStack(spacing: cellSpacing) { - // First row (Jan-Jun) - HStack(spacing: cellSpacing) { - ForEach(0..<6) { index in - MonthCellExpanded( - month: ["Jan", "Feb", "Mar", "Apr", "May", "Jun"][index], - viewsCount: monthlyData[index], - maxViews: maxViews - ) - } - } - - // Second row (Jul-Dec) - HStack(spacing: cellSpacing) { - ForEach(6..<12) { index in - MonthCellExpanded( - month: ["Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][index - 6], - viewsCount: monthlyData[index], - maxViews: maxViews - ) - } - } - } - } - } -} - -private struct MonthCellExpanded: View { - let month: String - let viewsCount: Int - let maxViews: Int - - private var intensity: Double { - min(1.0, Double(viewsCount) / Double(maxViews)) - } - - var body: some View { - VStack(spacing: 4) { - Text(month) - .font(.caption2) - .foregroundColor(.secondary) - - RoundedRectangle(cornerRadius: 6) - .fill(heatmapColor) - .frame(height: 44) - .overlay( - Text(StatsValueFormatter.formatNumber(viewsCount)) - .font(.subheadline.weight(.medium)) - .foregroundColor(intensity > 0.6 ? .white : .primary) - .minimumScaleFactor(0.8) - .lineLimit(1) - ) - } - .frame(maxWidth: .infinity) - } - - private var heatmapColor: Color { - Constants.heatmapColor(baseColor: Constants.Colors.blue, intensity: intensity) - } -} - #Preview { NavigationStack { PostStatsDetailsView( diff --git a/Modules/Sources/JetpackStats/Strings.swift b/Modules/Sources/JetpackStats/Strings.swift index 94938ffe57ec..cdaad0fde1d2 100644 --- a/Modules/Sources/JetpackStats/Strings.swift +++ b/Modules/Sources/JetpackStats/Strings.swift @@ -151,5 +151,29 @@ enum Strings { : AppLocalizedString("jetpackStats.postDetails.likes", value: "%1$d likes", comment: "Plural likes count. %1$d is the number.") return String.localizedStringWithFormat(format, count) } + + // Accessibility + static func weeklyActivityAccessibility(weeksCount: Int, metric: String, total: String) -> String { + String.localizedStringWithFormat( + AppLocalizedString("jetpackStats.postDetails.weeklyActivity.accessibility", + value: "Weekly activity heatmap showing %1$d weeks of %2$@ data. Total: %3$@", + comment: "VoiceOver description for weekly activity heatmap. %1$d is number of weeks, %2$@ is metric name, %3$@ is total value"), + weeksCount, metric, total + ) + } + + static func yearlyActivityAccessibility(yearsCount: Int, metric: String, total: String) -> String { + String.localizedStringWithFormat( + AppLocalizedString("jetpackStats.postDetails.yearlyActivity.accessibility", + value: "Yearly activity for %1$d years, %2$@: %3$@", + comment: "VoiceOver description for yearly activity heatmap. %1$d is number of years, %2$@ is metric name, %3$@ is total value"), + yearsCount, metric, total + ) + } + + // Tooltip + static let weekTotal = AppLocalizedString("jetpackStats.postDetails.weekTotal", value: "Week Total", comment: "Label for weekly total in tooltip") + 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") } } diff --git a/Modules/Sources/JetpackStats/Utilities/Modifiers/PopoverPresentationModifier.swift b/Modules/Sources/JetpackStats/Utilities/Modifiers/PopoverPresentationModifier.swift new file mode 100644 index 000000000000..7186cde5dfd2 --- /dev/null +++ b/Modules/Sources/JetpackStats/Utilities/Modifiers/PopoverPresentationModifier.swift @@ -0,0 +1,11 @@ +import SwiftUI + +struct PopoverPresentationModifier: ViewModifier { + func body(content: Content) -> some View { + if #available(iOS 16.4, *) { + content.presentationCompactAdaptation(.popover) + } else { + content + } + } +} diff --git a/Modules/Sources/JetpackStats/Views/CustomDateRangePicker.swift b/Modules/Sources/JetpackStats/Views/CustomDateRangePicker.swift index 89e293e5aa27..b63597bfd7ff 100644 --- a/Modules/Sources/JetpackStats/Views/CustomDateRangePicker.swift +++ b/Modules/Sources/JetpackStats/Views/CustomDateRangePicker.swift @@ -247,16 +247,6 @@ struct CustomDateRangePicker: View { } } -private struct PopoverPresentationModifier: ViewModifier { - func body(content: Content) -> some View { - if #available(iOS 16.4, *) { - content.presentationCompactAdaptation(.popover) - } else { - content - } - } -} - private struct QuickPeriodButtonView: View { let period: CustomDateRangePicker.QuickPeriod diff --git a/Modules/Sources/JetpackStats/Views/HeatmapView.swift b/Modules/Sources/JetpackStats/Views/HeatmapView.swift new file mode 100644 index 000000000000..3782554dd6e7 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/HeatmapView.swift @@ -0,0 +1,103 @@ +import SwiftUI + +// MARK: - HeatmapCellView + +/// A reusable heatmap cell view that displays a colored rectangle with an optional value label. +/// Used in both WeeklyTrendsView and YearlyTrendsView for consistent visual representation. +struct HeatmapCellView: View { + let value: Int + let formattedValue: String + let color: Color + let intensity: Double + + /// Creates a heatmap cell with automatic formatting and color calculation based on metric + init( + value: Int, + metric: SiteMetric, + maxValue: Int + ) { + let intensity = maxValue > 0 ? min(1.0, Double(value) / Double(maxValue)) : 0 + let formatter = StatsValueFormatter(metric: metric) + + self.init( + value: value, + formattedValue: formatter.format(value: value, context: .compact), + color: Constants.heatmapColor(baseColor: metric.primaryColor, intensity: intensity), + intensity: intensity + ) + } + + init( + value: Int, + formattedValue: String, + color: Color, + intensity: Double + ) { + self.value = value + self.formattedValue = formattedValue + self.color = color + self.intensity = intensity + } + + var body: some View { + RoundedRectangle(cornerRadius: 4) + .fill(color) + .overlay { + if value > 0 { + Text(formattedValue) + .font(.caption.weight(.medium)) + .foregroundStyle(.primary) + .minimumScaleFactor(0.5) + .lineLimit(1) + } + } + } +} + +// MARK: - HeatmapLegendView + +/// A reusable legend view for heatmaps showing the intensity gradient from less to more +struct HeatmapLegendView: View { + let metric: SiteMetric + let labelWidth: CGFloat? + + init(metric: SiteMetric, labelWidth: CGFloat? = nil) { + self.metric = metric + self.labelWidth = labelWidth + } + + var body: some View { + HStack(spacing: Constants.step2) { + HStack(spacing: 8) { + if let labelWidth = labelWidth { + Text(Strings.PostDetails.less) + .font(.caption2) + .foregroundColor(.secondary) + .frame(width: labelWidth, alignment: .trailing) + } else { + Text(Strings.PostDetails.less) + .font(.caption2) + .foregroundColor(.secondary) + } + + HStack(spacing: 3) { + ForEach(0..<5) { level in + RoundedRectangle(cornerRadius: 4) + .fill(heatmapColor(for: Double(level) / 4.0)) + .frame(width: 16, height: 16) + } + } + + Text(Strings.PostDetails.more) + .font(.caption2) + .foregroundColor(.secondary) + } + + Spacer() + } + } + + private func heatmapColor(for intensity: Double) -> Color { + Constants.heatmapColor(baseColor: metric.primaryColor, intensity: intensity) + } +} diff --git a/Modules/Sources/JetpackStats/Views/WeeklyTrendsView.swift b/Modules/Sources/JetpackStats/Views/WeeklyTrendsView.swift index 9e73f15276de..85f7d8ae9239 100644 --- a/Modules/Sources/JetpackStats/Views/WeeklyTrendsView.swift +++ b/Modules/Sources/JetpackStats/Views/WeeklyTrendsView.swift @@ -2,31 +2,21 @@ import SwiftUI import WordPressKit struct WeeklyTrendsView: View { - let weeks: [Week] - let calendar: Calendar - let timeZone: TimeZone - let metric: SiteMetric = .views + let viewModel: WeeklyTrendsViewModel private let cellSpacing: CGFloat = 4 private let weekLabelWidth: CGFloat = 36 - - private var maxValue: Int { - weeks.flatMap { $0.days }.map { $0.value }.max() ?? 1 - } - private var dayLabels: [String] { - // Get localized very short weekday symbols from Calendar - let formatter = DateFormatter() - formatter.calendar = calendar - formatter.locale = calendar.locale ?? Locale.current - - // Get weekday symbols in the order defined by the calendar's firstWeekday - let symbols = formatter.veryShortWeekdaySymbols ?? [] - let firstWeekday = calendar.firstWeekday - - // Reorder symbols to start with the calendar's first weekday - let reorderedSymbols = Array(symbols[(firstWeekday - 1)...]) + Array(symbols[..<(firstWeekday - 1)]) - return reorderedSymbols + @State private var selectedDay: Week.Day? + @State private var selectedWeek: Week? + + init(weeks: [Week], calendar: Calendar, timeZone: TimeZone, metric: SiteMetric = .views) { + self.viewModel = WeeklyTrendsViewModel( + weeks: weeks, + calendar: calendar, + timeZone: timeZone, + metric: metric + ) } struct Week { @@ -56,92 +46,156 @@ struct WeeklyTrendsView: View { var body: some View { VStack(alignment: .leading, spacing: cellSpacing) { - // Day labels header - HStack(spacing: 0) { - Color.clear - .frame(width: weekLabelWidth) - - HStack(spacing: cellSpacing) { - ForEach(dayLabels, id: \.self) { day in - Text(day) - .font(.caption2) - .foregroundColor(.secondary) - .frame(maxWidth: .infinity) - .multilineTextAlignment(.center) - } - } - } - - // Heatmap grid - VStack(spacing: cellSpacing) { - // Show last 4 weeks, 7 days per week - ForEach(Array(weeks.prefix(4).enumerated()), id: \.offset) { weekIndex, week in - HStack(spacing: 8) { - // Week label - Text(weekLabel(for: week)) - .font(.caption2) - .foregroundColor(.secondary) - .frame(width: weekLabelWidth, alignment: .trailing) - - HStack(spacing: cellSpacing) { - // Days in the week - ForEach(week.days, id: \.date) { day in - DayCell( - value: day.value, - maxValue: maxValue, - metric: metric - ) - .frame(maxWidth: .infinity) - .aspectRatio(1, contentMode: .fit) - } - } - } - } - } - - // Legend + header + heatmap legend .padding(.top, Constants.step1) } .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityLabel) } - private var legend: some View { - HStack(spacing: Constants.step1) { - Text(Strings.PostDetails.less) - .font(.caption2) - .foregroundColor(.secondary) - - HStack(spacing: 4) { - ForEach(0..<5) { level in - RoundedRectangle(cornerRadius: 4) - .fill(Constants.heatmapColor(baseColor: metric.primaryColor, intensity: Double(level) / 4.0)) - .frame(width: 16, height: 16) + private var header: some View { + HStack(spacing: 0) { + Color.clear + .frame(width: weekLabelWidth) + + HStack(spacing: cellSpacing) { + ForEach(viewModel.dayLabels, id: \.self) { day in + Text(day) + .font(.caption2) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) + .accessibilityHidden(true) } } + } + } - Text(Strings.PostDetails.more) - .font(.caption2) - .foregroundColor(.secondary) + private var heatmap: some View { + VStack(spacing: cellSpacing) { + // Show last 4 weeks, 7 days per week + ForEach(Array(viewModel.weeks.prefix(4).enumerated()), id: \.offset) { weekIndex, week in + HStack(spacing: 8) { + // Week label + Text(viewModel.weekLabel(for: week)) + .font(.caption2) + .foregroundColor(.secondary) + .frame(width: weekLabelWidth, alignment: .trailing) + + HStack(spacing: cellSpacing) { + // Days in the week + ForEach(week.days, id: \.date) { day in + DayCell( + day: day, + week: week, + previousWeek: viewModel.previousWeek(for: week), + maxValue: viewModel.maxValue, + metric: viewModel.metric, + formatter: viewModel, + calendar: viewModel.calendar + ) + .frame(maxWidth: .infinity) + .aspectRatio(1, contentMode: .fit) + } + } + } + } } } - private func weekLabel(for week: Week) -> String { + private var legend: some View { + HeatmapLegendView(metric: viewModel.metric, labelWidth: weekLabelWidth) + } + + + private var accessibilityLabel: String { + let weeksCount = min(viewModel.weeks.count, 4) + let totalValue = viewModel.weeks.prefix(4).flatMap { $0.days }.reduce(0) { $0 + $1.value } + let formattedTotal = viewModel.formatValue(totalValue) + + return Strings.PostDetails.weeklyActivityAccessibility(weeksCount: weeksCount, metric: viewModel.metric.localizedTitle, total: formattedTotal) + } +} + +@MainActor +final class WeeklyTrendsViewModel: ObservableObject { + let weeks: [WeeklyTrendsView.Week] + let calendar: Calendar + let timeZone: TimeZone + let metric: SiteMetric + + private let valueFormatter: StatsValueFormatter + private let weekFormatter: DateFormatter + + let dayLabels: [String] + let maxValue: Int + + init(weeks: [WeeklyTrendsView.Week], calendar: Calendar, timeZone: TimeZone, metric: SiteMetric = .views) { + self.weeks = weeks + self.calendar = calendar + self.timeZone = timeZone + self.metric = metric + + // Initialize formatters + self.valueFormatter = StatsValueFormatter(metric: metric) + + self.weekFormatter = DateFormatter() + self.weekFormatter.dateFormat = "MMM d" + self.weekFormatter.timeZone = timeZone + + // Cache day labels let formatter = DateFormatter() - formatter.dateFormat = "MMM d" - formatter.timeZone = timeZone - return formatter.string(from: week.startDate) + formatter.calendar = calendar + formatter.locale = calendar.locale ?? Locale.current + + // Get weekday symbols in the order defined by the calendar's firstWeekday + let symbols = formatter.veryShortWeekdaySymbols ?? [] + let firstWeekday = calendar.firstWeekday + + // Reorder symbols to start with the calendar's first weekday + let reorderedSymbols = Array(symbols[(firstWeekday - 1)...]) + Array(symbols[..<(firstWeekday - 1)]) + self.dayLabels = reorderedSymbols + + // Calculate max value once + self.maxValue = weeks.flatMap { $0.days }.map { $0.value }.max() ?? 1 } - - private func heatmapColor(for intensity: Double) -> Color { + + func weekLabel(for week: WeeklyTrendsView.Week) -> String { + weekFormatter.string(from: week.startDate) + } + + func formatValue(_ value: Int) -> String { + valueFormatter.format(value: value, context: .compact) + } + + func heatmapColor(for intensity: Double) -> Color { Constants.heatmapColor(baseColor: metric.primaryColor, intensity: intensity) } + + func previousWeek(for week: WeeklyTrendsView.Week) -> WeeklyTrendsView.Week? { + guard let weekIndex = weeks.firstIndex(where: { $0.startDate == week.startDate }), + weekIndex < weeks.count - 1 else { + return nil + } + return weeks[weekIndex + 1] + } } private struct DayCell: View { - let value: Int + let day: WeeklyTrendsView.Week.Day + let week: WeeklyTrendsView.Week + let previousWeek: WeeklyTrendsView.Week? let maxValue: Int let metric: SiteMetric + let formatter: WeeklyTrendsViewModel + let calendar: Calendar + + @State private var showingPopover = false + + private var value: Int { day.value } private var intensity: Double { guard maxValue > 0 else { @@ -151,24 +205,137 @@ private struct DayCell: View { } var body: some View { - RoundedRectangle(cornerRadius: 4) - .fill(heatmapColor) - .overlay { - if value > 0 { - Text(formattedValue) - .font(.caption.weight(.medium)) - .foregroundColor(.primary) - .foregroundColor(intensity > 0.75 ? Color(.systemBackground) : .primary) - } - } + HeatmapCellView( + value: value, + metric: metric, + maxValue: maxValue + ) + .onTapGesture { + showingPopover = true + } + .popover(isPresented: $showingPopover) { + WeeklyTrendsTooltipView( + day: day, + week: week, + previousWeek: previousWeek, + metric: metric, + calendar: calendar, + formatter: formatter + ) + .modifier(PopoverPresentationModifier()) + } + .accessibilityElement() + .accessibilityLabel(accessibilityLabel) + .accessibilityAddTraits(.isButton) + } + + + private var accessibilityLabel: String { + let dateFormatter = DateFormatter() + dateFormatter.dateStyle = .medium + dateFormatter.timeStyle = .none + dateFormatter.calendar = calendar + + let dateString = dateFormatter.string(from: day.date) + let valueString = formatter.formatValue(value) + + return "\(dateString), \(valueString) \(metric.localizedTitle)" } +} - private var formattedValue: String { - StatsValueFormatter(metric: metric).format(value: value, context: .compact) +private struct WeeklyTrendsTooltipView: View { + let day: WeeklyTrendsView.Week.Day + let week: WeeklyTrendsView.Week + let previousWeek: WeeklyTrendsView.Week? + let metric: SiteMetric + let calendar: Calendar + let formatter: WeeklyTrendsViewModel + + private var weekTotal: Int { + week.days.reduce(0) { $0 + $1.value } + } + + private var previousWeekTotal: Int { + previousWeek?.days.reduce(0) { $0 + $1.value } ?? 0 + } + + private var averagePerDay: Int { + week.days.isEmpty ? 0 : weekTotal / week.days.count } + + private var trendViewModel: TrendViewModel { + TrendViewModel( + currentValue: weekTotal, + previousValue: previousWeekTotal, + metric: metric, + context: .regular + ) + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + // Date header + Text(formattedDate) + .font(.subheadline) + .fontWeight(.semibold) + + // Day value + HStack(spacing: 6) { + Circle() + .fill(metric.primaryColor) + .frame(width: 8, height: 8) + Text(formatter.formatValue(day.value)) + .font(.subheadline) + .fontWeight(.medium) + Text(metric.localizedTitle) + .font(.subheadline) + .foregroundColor(.secondary) + } - private var heatmapColor: Color { - Constants.heatmapColor(baseColor: metric.primaryColor, intensity: intensity) + // Week stats + VStack(alignment: .leading, spacing: 4) { + // Week total + HStack(spacing: 4) { + Text(Strings.PostDetails.weekTotal) + .font(.caption) + .foregroundColor(.secondary) + Text(formatter.formatValue(weekTotal)) + .font(.caption) + .fontWeight(.medium) + } + + // Average per day + HStack(spacing: 4) { + Text(Strings.PostDetails.dailyAverage) + .font(.caption) + .foregroundColor(.secondary) + Text(formatter.formatValue(averagePerDay)) + .font(.caption) + .fontWeight(.medium) + } + + // Week-over-week change + if previousWeek != nil && (weekTotal != previousWeekTotal) { + HStack(spacing: 4) { + Text(Strings.PostDetails.weekOverWeek) + .font(.caption) + .foregroundColor(.secondary) + Text(trendViewModel.formattedTrendShort) + .font(.caption) + .fontWeight(.medium) + .foregroundColor(trendViewModel.sentiment.foregroundColor) + } + } + } + } + .padding() + } + + private var formattedDate: String { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "MMM d, yyyy" + dateFormatter.calendar = calendar + return dateFormatter.string(from: day.date) } } @@ -237,7 +404,8 @@ extension WeeklyTrendsView.Week { WeeklyTrendsView( weeks: WeeklyTrendsView.Week.mockWeeks(count: 4), calendar: StatsContext.demo.calendar, - timeZone: StatsContext.demo.timeZone + timeZone: StatsContext.demo.timeZone, + metric: .views ) .padding(Constants.step2) .cardStyle() @@ -245,7 +413,8 @@ extension WeeklyTrendsView.Week { WeeklyTrendsView( weeks: Array(WeeklyTrendsView.Week.mockHighTraffic.prefix(4)), calendar: StatsContext.demo.calendar, - timeZone: StatsContext.demo.timeZone + timeZone: StatsContext.demo.timeZone, + metric: .views ) .padding(Constants.step2) .cardStyle() @@ -253,7 +422,8 @@ extension WeeklyTrendsView.Week { WeeklyTrendsView( weeks: Array(WeeklyTrendsView.Week.mockEmpty.prefix(4)), calendar: StatsContext.demo.calendar, - timeZone: StatsContext.demo.timeZone + timeZone: StatsContext.demo.timeZone, + metric: .views ) .padding(Constants.step2) .cardStyle() diff --git a/Modules/Sources/JetpackStats/Views/YearlyTrendsView.swift b/Modules/Sources/JetpackStats/Views/YearlyTrendsView.swift new file mode 100644 index 000000000000..dc55f0728689 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/YearlyTrendsView.swift @@ -0,0 +1,292 @@ +import SwiftUI +import WordPressKit + +struct YearlyTrendsView: View { + let viewModel: YearlyTrendsViewModel + + private let cellSpacing: CGFloat = 6 + private let yearLabelWidth: CGFloat = 36 + + init(dataPoints: [DataPoint], calendar: Calendar, timeZone: TimeZone, metric: SiteMetric = .views) { + self.viewModel = YearlyTrendsViewModel( + dataPoints: dataPoints, + calendar: calendar, + timeZone: timeZone, + metric: metric + ) + } + + var body: some View { + VStack(alignment: .leading, spacing: Constants.step2) { + yearlyHeatmap + legend + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityLabel) + } + + private var yearlyHeatmap: some View { + VStack(spacing: cellSpacing) { + ForEach(viewModel.sortedYears, id: \.self) { year in + yearRow(for: year) + } + } + } + + @ViewBuilder + private func yearRow(for year: Int) -> some View { + let monthlyData = viewModel.getMonthlyData(for: year) + + VStack(spacing: cellSpacing) { + // First row: Jul-Dec (top) + HStack(spacing: 8) { + Text(String(year)) + .font(.caption) + .foregroundColor(.secondary) + .frame(width: yearLabelWidth, alignment: .trailing) + + HStack(spacing: cellSpacing) { + ForEach(6..<12) { index in + monthCell( + month: viewModel.monthLabels[index], + year: year, + viewsCount: monthlyData[index] + ) + .frame(maxWidth: .infinity) + .aspectRatio(1, contentMode: .fit) + } + } + } + + // Second row: Jan-Jun (bottom) + HStack(spacing: 8) { + Color.clear + .frame(width: yearLabelWidth) + + HStack(spacing: cellSpacing) { + ForEach(0..<6) { index in + monthCell( + month: viewModel.monthLabels[index], + year: year, + viewsCount: monthlyData[index] + ) + .frame(maxWidth: .infinity) + .aspectRatio(1, contentMode: .fit) + } + } + } + } + } + + @State private var showingPopover = false + @State private var selectedMonth: (month: String, year: Int, viewsCount: Int)? + + @ViewBuilder + private func monthCell(month: String, year: Int, viewsCount: Int) -> some View { + HeatmapCellView( + value: viewsCount, + metric: viewModel.metric, + maxValue: viewModel.maxMonthlyViews + ) + .onTapGesture { + selectedMonth = (month: month, year: year, viewsCount: viewsCount) + showingPopover = true + } + .popover(isPresented: $showingPopover) { + if let selected = selectedMonth { + MonthlyTrendsTooltipView( + month: selected.month, + year: selected.year, + viewsCount: selected.viewsCount, + metric: viewModel.metric, + formatter: viewModel + ) + .modifier(PopoverPresentationModifier()) + } + } + .accessibilityElement() + .accessibilityLabel("\(month) \(year), \(viewModel.formatValue(viewsCount)) \(viewModel.metric.localizedTitle)") + .accessibilityAddTraits(.isButton) + } + + private var legend: some View { + HeatmapLegendView(metric: viewModel.metric, labelWidth: yearLabelWidth) + } + + private var accessibilityLabel: String { + let yearsCount = min(viewModel.sortedYears.count, 3) + let totalValue = viewModel.sortedYears.prefix(3).reduce(0) { sum, year in + sum + (viewModel.yearlyTotals[year] ?? 0) + } + let formattedTotal = viewModel.formatValue(totalValue) + + return Strings.PostDetails.yearlyActivityAccessibility( + yearsCount: yearsCount, + metric: viewModel.metric.localizedTitle, + total: formattedTotal + ) + } +} + +@MainActor +final class YearlyTrendsViewModel: ObservableObject { + let dataPoints: [DataPoint] + let calendar: Calendar + let timeZone: TimeZone + let metric: SiteMetric + + private let valueFormatter: StatsValueFormatter + + let monthLabels: [String] + let yearlyTotals: [Int: Int] + let sortedYears: [Int] + let maxMonthlyViews: Int + + private var monthlyData: [Int: [Int: Int]] = [:] // year -> month -> total views + + init(dataPoints: [DataPoint], calendar: Calendar, timeZone: TimeZone, metric: SiteMetric = .views) { + self.dataPoints = dataPoints + self.calendar = calendar + self.timeZone = timeZone + self.metric = metric + + self.valueFormatter = StatsValueFormatter(metric: metric) + + // Generate month labels using Calendar + let formatter = DateFormatter() + formatter.calendar = calendar + formatter.locale = calendar.locale ?? Locale.current + self.monthLabels = formatter.shortMonthSymbols ?? ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] + + // Process data points to compute yearly and monthly totals + var yearlyTotals: [Int: Int] = [:] + var monthlyData: [Int: [Int: Int]] = [:] + var maxMonthlyViews = 0 + + // Configure calendar with the correct time zone + var localCalendar = calendar + localCalendar.timeZone = timeZone + + for dataPoint in dataPoints { + let components = localCalendar.dateComponents([.year, .month], from: dataPoint.date) + guard let year = components.year, let month = components.month else { continue } + + // Update yearly total + yearlyTotals[year, default: 0] += dataPoint.value + + // Update monthly total + if monthlyData[year] == nil { + monthlyData[year] = [:] + } + monthlyData[year]?[month, default: 0] += dataPoint.value + + // Track max monthly value + let monthTotal = monthlyData[year]?[month] ?? 0 + maxMonthlyViews = max(maxMonthlyViews, monthTotal) + } + + self.yearlyTotals = yearlyTotals + self.monthlyData = monthlyData + self.sortedYears = yearlyTotals.keys.sorted(by: >) + self.maxMonthlyViews = max(maxMonthlyViews, 1) // Avoid division by zero + } + + func getMonthlyData(for year: Int) -> [Int] { + var monthlyViews = Array(repeating: 0, count: 12) + + if let yearData = monthlyData[year] { + for (month, views) in yearData { + if month >= 1 && month <= 12 { + monthlyViews[month - 1] = views + } + } + } + + return monthlyViews + } + + func formatValue(_ value: Int) -> String { + valueFormatter.format(value: value, context: .compact) + } + + func heatmapColor(for intensity: Double) -> Color { + Constants.heatmapColor(baseColor: metric.primaryColor, intensity: intensity) + } +} + + + +private struct MonthlyTrendsTooltipView: View { + let month: String + let year: Int + let viewsCount: Int + let metric: SiteMetric + let formatter: YearlyTrendsViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + // Month header + Text("\(month) \(year)") + .font(.subheadline) + .fontWeight(.semibold) + + // Month value + HStack(spacing: 6) { + Circle() + .fill(metric.primaryColor) + .frame(width: 8, height: 8) + Text(formatter.formatValue(viewsCount)) + .font(.subheadline) + .fontWeight(.medium) + Text(metric.localizedTitle) + .font(.subheadline) + .foregroundColor(.secondary) + } + } + .padding() + } +} + +// MARK: - Previews + +#Preview { + ScrollView { + VStack(spacing: Constants.step2) { + YearlyTrendsView( + dataPoints: mockDataPoints(), + calendar: Calendar.current, + timeZone: TimeZone.current, + metric: .views + ) + .padding(Constants.step2) + .cardStyle() + } + } + .background(Constants.Colors.background) +} + +private func mockDataPoints() -> [DataPoint] { + var dataPoints: [DataPoint] = [] + let calendar = Calendar.current + + for year in [2021, 2022, 2023, 2024] { + for month in 1...12 { + // Skip future months + if year == 2024 && month > 7 { continue } + + // Generate daily data points for each month + let daysInMonth = calendar.range(of: .day, in: .month, for: calendar.date(from: DateComponents(year: year, month: month))!)?.count ?? 30 + + for day in 1...daysInMonth { + if let date = calendar.date(from: DateComponents(year: year, month: month, day: day)) { + let baseViews = year == 2024 ? 500 : (year == 2023 ? 400 : 200) + let viewsCount = Int.random(in: (baseViews / 2)...baseViews) + dataPoints.append(DataPoint(date: date, value: viewsCount)) + } + } + } + } + + return dataPoints +} From a8a2d997568e1d10bbf7d48aa4444c5956c3c8db Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Thu, 24 Jul 2025 12:16:36 -0400 Subject: [PATCH 041/349] Improve YearlyTrendsView --- .../Screens/PostStatsDetailsView.swift | 8 +- .../JetpackStats/Views/YearlyTrendsView.swift | 225 +++++++++--------- 2 files changed, 123 insertions(+), 110 deletions(-) diff --git a/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift b/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift index a25f98660419..79fe459f64da 100644 --- a/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift +++ b/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift @@ -77,9 +77,11 @@ struct PostStatsDetailsView: View { StatsCardTitleView(title: Strings.PostDetails.monthlyActivity) YearlyTrendsView( - dataPoints: dataPoints, - calendar: context.calendar, - timeZone: context.timeZone + viewModel: YearlyTrendsViewModel( + dataPoints: dataPoints, + calendar: context.calendar, + timeZone: context.timeZone + ) ) } .padding(Constants.step2) diff --git a/Modules/Sources/JetpackStats/Views/YearlyTrendsView.swift b/Modules/Sources/JetpackStats/Views/YearlyTrendsView.swift index dc55f0728689..9a46c22a98ca 100644 --- a/Modules/Sources/JetpackStats/Views/YearlyTrendsView.swift +++ b/Modules/Sources/JetpackStats/Views/YearlyTrendsView.swift @@ -7,13 +7,8 @@ struct YearlyTrendsView: View { private let cellSpacing: CGFloat = 6 private let yearLabelWidth: CGFloat = 36 - init(dataPoints: [DataPoint], calendar: Calendar, timeZone: TimeZone, metric: SiteMetric = .views) { - self.viewModel = YearlyTrendsViewModel( - dataPoints: dataPoints, - calendar: calendar, - timeZone: timeZone, - metric: metric - ) + init(viewModel: YearlyTrendsViewModel) { + self.viewModel = viewModel } var body: some View { @@ -22,8 +17,6 @@ struct YearlyTrendsView: View { legend } .frame(maxWidth: .infinity, alignment: .leading) - .accessibilityElement(children: .combine) - .accessibilityLabel(accessibilityLabel) } private var yearlyHeatmap: some View { @@ -48,13 +41,16 @@ struct YearlyTrendsView: View { HStack(spacing: cellSpacing) { ForEach(6..<12) { index in - monthCell( - month: viewModel.monthLabels[index], - year: year, - viewsCount: monthlyData[index] - ) - .frame(maxWidth: .infinity) - .aspectRatio(1, contentMode: .fit) + if let monthItem = monthlyData[index] { + monthCell(monthItem: monthItem) + .frame(maxWidth: .infinity) + .aspectRatio(1, contentMode: .fit) + } else { + // Empty cell for months with no data + Color.clear + .frame(maxWidth: .infinity) + .aspectRatio(1, contentMode: .fit) + } } } } @@ -66,168 +62,175 @@ struct YearlyTrendsView: View { HStack(spacing: cellSpacing) { ForEach(0..<6) { index in - monthCell( - month: viewModel.monthLabels[index], - year: year, - viewsCount: monthlyData[index] - ) - .frame(maxWidth: .infinity) - .aspectRatio(1, contentMode: .fit) + if let monthItem = monthlyData[index] { + monthCell(monthItem: monthItem) + .frame(maxWidth: .infinity) + .aspectRatio(1, contentMode: .fit) + } else { + // Empty cell for months with no data + Color.clear + .frame(maxWidth: .infinity) + .aspectRatio(1, contentMode: .fit) + } } } } } } - @State private var showingPopover = false - @State private var selectedMonth: (month: String, year: Int, viewsCount: Int)? - @ViewBuilder - private func monthCell(month: String, year: Int, viewsCount: Int) -> some View { - HeatmapCellView( - value: viewsCount, + private func monthCell(monthItem: YearlyTrendsViewModel.MonthItem) -> some View { + MonthCell( + monthItem: monthItem, metric: viewModel.metric, - maxValue: viewModel.maxMonthlyViews + maxValue: viewModel.maxMonthlyViews, + formatter: viewModel ) - .onTapGesture { - selectedMonth = (month: month, year: year, viewsCount: viewsCount) - showingPopover = true - } - .popover(isPresented: $showingPopover) { - if let selected = selectedMonth { - MonthlyTrendsTooltipView( - month: selected.month, - year: selected.year, - viewsCount: selected.viewsCount, - metric: viewModel.metric, - formatter: viewModel - ) - .modifier(PopoverPresentationModifier()) - } - } - .accessibilityElement() - .accessibilityLabel("\(month) \(year), \(viewModel.formatValue(viewsCount)) \(viewModel.metric.localizedTitle)") - .accessibilityAddTraits(.isButton) } private var legend: some View { HeatmapLegendView(metric: viewModel.metric, labelWidth: yearLabelWidth) } - - private var accessibilityLabel: String { - let yearsCount = min(viewModel.sortedYears.count, 3) - let totalValue = viewModel.sortedYears.prefix(3).reduce(0) { sum, year in - sum + (viewModel.yearlyTotals[year] ?? 0) - } - let formattedTotal = viewModel.formatValue(totalValue) - - return Strings.PostDetails.yearlyActivityAccessibility( - yearsCount: yearsCount, - metric: viewModel.metric.localizedTitle, - total: formattedTotal - ) - } } @MainActor final class YearlyTrendsViewModel: ObservableObject { - let dataPoints: [DataPoint] - let calendar: Calendar - let timeZone: TimeZone + struct MonthItem { + let date: Date // Beginning of month + let value: Int + } + let metric: SiteMetric private let valueFormatter: StatsValueFormatter - let monthLabels: [String] - let yearlyTotals: [Int: Int] let sortedYears: [Int] let maxMonthlyViews: Int - private var monthlyData: [Int: [Int: Int]] = [:] // year -> month -> total views + private var monthlyData: [Int: [Int: MonthItem]] = [:] // year -> month -> MonthItem init(dataPoints: [DataPoint], calendar: Calendar, timeZone: TimeZone, metric: SiteMetric = .views) { - self.dataPoints = dataPoints - self.calendar = calendar - self.timeZone = timeZone self.metric = metric self.valueFormatter = StatsValueFormatter(metric: metric) - // Generate month labels using Calendar - let formatter = DateFormatter() - formatter.calendar = calendar - formatter.locale = calendar.locale ?? Locale.current - self.monthLabels = formatter.shortMonthSymbols ?? ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] - - // Process data points to compute yearly and monthly totals - var yearlyTotals: [Int: Int] = [:] - var monthlyData: [Int: [Int: Int]] = [:] + // Process data points to compute monthly totals + var monthlyData: [Int: [Int: MonthItem]] = [:] + var monthlyTotals: [String: Int] = [:] // Temporary storage for accumulating values var maxMonthlyViews = 0 // Configure calendar with the correct time zone var localCalendar = calendar localCalendar.timeZone = timeZone + // First pass: accumulate values by year-month for dataPoint in dataPoints { let components = localCalendar.dateComponents([.year, .month], from: dataPoint.date) guard let year = components.year, let month = components.month else { continue } - // Update yearly total - yearlyTotals[year, default: 0] += dataPoint.value + let key = "\(year)-\(month)" + monthlyTotals[key, default: 0] += dataPoint.value + } + + // Second pass: create MonthItems with beginning-of-month dates + for (key, value) in monthlyTotals { + let parts = key.split(separator: "-") + guard parts.count == 2, + let year = Int(parts[0]), + let month = Int(parts[1]) else { continue } + + // Create date at beginning of month + var dateComponents = DateComponents() + dateComponents.year = year + dateComponents.month = month + dateComponents.day = 1 + + guard let monthDate = localCalendar.date(from: dateComponents) else { continue } - // Update monthly total if monthlyData[year] == nil { monthlyData[year] = [:] } - monthlyData[year]?[month, default: 0] += dataPoint.value + + let monthItem = MonthItem(date: monthDate, value: value) + monthlyData[year]?[month] = monthItem // Track max monthly value - let monthTotal = monthlyData[year]?[month] ?? 0 - maxMonthlyViews = max(maxMonthlyViews, monthTotal) + maxMonthlyViews = max(maxMonthlyViews, value) } - self.yearlyTotals = yearlyTotals self.monthlyData = monthlyData - self.sortedYears = yearlyTotals.keys.sorted(by: >) + self.sortedYears = monthlyData.keys.sorted(by: >) self.maxMonthlyViews = max(maxMonthlyViews, 1) // Avoid division by zero } - func getMonthlyData(for year: Int) -> [Int] { - var monthlyViews = Array(repeating: 0, count: 12) + func getMonthlyData(for year: Int) -> [MonthItem?] { + var monthlyItems: [MonthItem?] = Array(repeating: nil, count: 12) if let yearData = monthlyData[year] { - for (month, views) in yearData { + for (month, item) in yearData { if month >= 1 && month <= 12 { - monthlyViews[month - 1] = views + monthlyItems[month - 1] = item } } } - return monthlyViews + return monthlyItems } func formatValue(_ value: Int) -> String { valueFormatter.format(value: value, context: .compact) } +} + +private struct MonthCell: View { + let monthItem: YearlyTrendsViewModel.MonthItem + let metric: SiteMetric + let maxValue: Int + let formatter: YearlyTrendsViewModel + + @State private var showingPopover = false - func heatmapColor(for intensity: Double) -> Color { - Constants.heatmapColor(baseColor: metric.primaryColor, intensity: intensity) + var body: some View { + HeatmapCellView( + value: monthItem.value, + metric: metric, + maxValue: maxValue + ) + .onTapGesture { + showingPopover = true + } + .popover(isPresented: $showingPopover) { + MonthlyTrendsTooltipView( + date: monthItem.date, + value: monthItem.value, + metric: metric, + formatter: formatter + ) + .modifier(PopoverPresentationModifier()) + } + .accessibilityElement() + .accessibilityLabel(accessibilityLabel) + .accessibilityAddTraits(.isButton) + } + + private var accessibilityLabel: String { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "MMMM yyyy" + let dateString = dateFormatter.string(from: monthItem.date) + return "\(dateString), \(formatter.formatValue(monthItem.value)) \(metric.localizedTitle)" } } - - private struct MonthlyTrendsTooltipView: View { - let month: String - let year: Int - let viewsCount: Int + let date: Date + let value: Int let metric: SiteMetric let formatter: YearlyTrendsViewModel var body: some View { VStack(alignment: .leading, spacing: 8) { // Month header - Text("\(month) \(year)") + Text(formattedDate) .font(.subheadline) .fontWeight(.semibold) @@ -236,7 +239,7 @@ private struct MonthlyTrendsTooltipView: View { Circle() .fill(metric.primaryColor) .frame(width: 8, height: 8) - Text(formatter.formatValue(viewsCount)) + Text(formatter.formatValue(value)) .font(.subheadline) .fontWeight(.medium) Text(metric.localizedTitle) @@ -246,6 +249,12 @@ private struct MonthlyTrendsTooltipView: View { } .padding() } + + private var formattedDate: String { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "MMMM yyyy" + return dateFormatter.string(from: date) + } } // MARK: - Previews @@ -254,10 +263,12 @@ private struct MonthlyTrendsTooltipView: View { ScrollView { VStack(spacing: Constants.step2) { YearlyTrendsView( - dataPoints: mockDataPoints(), - calendar: Calendar.current, - timeZone: TimeZone.current, - metric: .views + viewModel: YearlyTrendsViewModel( + dataPoints: mockDataPoints(), + calendar: Calendar.current, + timeZone: TimeZone.current, + metric: .views + ) ) .padding(Constants.step2) .cardStyle() From ec6419a90c79262ee546c3392dc2dcb1ed96b3ac Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Thu, 24 Jul 2025 12:23:07 -0400 Subject: [PATCH 042/349] Use StatsDataAggregator in YearlyTrendsViewModel --- .../Services/Mocks/StatsDataAggregator.swift | 9 +- .../JetpackStats/Views/YearlyTrendsView.swift | 84 ++++++++----------- 2 files changed, 36 insertions(+), 57 deletions(-) diff --git a/Modules/Sources/JetpackStats/Services/Mocks/StatsDataAggregator.swift b/Modules/Sources/JetpackStats/Services/Mocks/StatsDataAggregator.swift index df680ce9f757..8d5a1cbeb9bc 100644 --- a/Modules/Sources/JetpackStats/Services/Mocks/StatsDataAggregator.swift +++ b/Modules/Sources/JetpackStats/Services/Mocks/StatsDataAggregator.swift @@ -36,7 +36,7 @@ struct AggregatedDataPoint { /// // ] /// ``` struct StatsDataAggregator { - var calendar: Calendar = .current + var calendar: Calendar /// Aggregates data points based on the given granularity. func aggregate(_ dataPoints: [DataPoint], granularity: DateRangeGranularity) -> [Date: AggregatedDataPoint] { @@ -130,11 +130,8 @@ struct StatsDataAggregator { let normalizedData = normalizeForMetric(aggregatedData, metric: metric) // Generate complete date sequence for the range - let dateSequence = generateDateSequence( - dateInterval: dateInterval, - by: granularity.component - ) - + let dateSequence = generateDateSequence(dateInterval: dateInterval, by: granularity.component) + // Create data points for each date in the sequence let periodDataPoints = dateSequence.map { date in let aggregationDate = makeAggegationDate(for: date, granularity: granularity) diff --git a/Modules/Sources/JetpackStats/Views/YearlyTrendsView.swift b/Modules/Sources/JetpackStats/Views/YearlyTrendsView.swift index 9a46c22a98ca..4b0a0d2258de 100644 --- a/Modules/Sources/JetpackStats/Views/YearlyTrendsView.swift +++ b/Modules/Sources/JetpackStats/Views/YearlyTrendsView.swift @@ -41,8 +41,8 @@ struct YearlyTrendsView: View { HStack(spacing: cellSpacing) { ForEach(6..<12) { index in - if let monthItem = monthlyData[index] { - monthCell(monthItem: monthItem) + if let dataPoint = monthlyData[index] { + monthCell(dataPoint: dataPoint) .frame(maxWidth: .infinity) .aspectRatio(1, contentMode: .fit) } else { @@ -62,8 +62,8 @@ struct YearlyTrendsView: View { HStack(spacing: cellSpacing) { ForEach(0..<6) { index in - if let monthItem = monthlyData[index] { - monthCell(monthItem: monthItem) + if let dataPoint = monthlyData[index] { + monthCell(dataPoint: dataPoint) .frame(maxWidth: .infinity) .aspectRatio(1, contentMode: .fit) } else { @@ -79,9 +79,9 @@ struct YearlyTrendsView: View { } @ViewBuilder - private func monthCell(monthItem: YearlyTrendsViewModel.MonthItem) -> some View { + private func monthCell(dataPoint: DataPoint) -> some View { MonthCell( - monthItem: monthItem, + dataPoint: dataPoint, metric: viewModel.metric, maxValue: viewModel.maxMonthlyViews, formatter: viewModel @@ -95,64 +95,46 @@ struct YearlyTrendsView: View { @MainActor final class YearlyTrendsViewModel: ObservableObject { - struct MonthItem { - let date: Date // Beginning of month - let value: Int - } - let metric: SiteMetric private let valueFormatter: StatsValueFormatter + private let aggregator: StatsDataAggregator let sortedYears: [Int] let maxMonthlyViews: Int - private var monthlyData: [Int: [Int: MonthItem]] = [:] // year -> month -> MonthItem + private var monthlyData: [Int: [Int: DataPoint]] = [:] // year -> month -> DataPoint init(dataPoints: [DataPoint], calendar: Calendar, timeZone: TimeZone, metric: SiteMetric = .views) { self.metric = metric self.valueFormatter = StatsValueFormatter(metric: metric) - // Process data points to compute monthly totals - var monthlyData: [Int: [Int: MonthItem]] = [:] - var monthlyTotals: [String: Int] = [:] // Temporary storage for accumulating values - var maxMonthlyViews = 0 - // Configure calendar with the correct time zone var localCalendar = calendar localCalendar.timeZone = timeZone - // First pass: accumulate values by year-month - for dataPoint in dataPoints { - let components = localCalendar.dateComponents([.year, .month], from: dataPoint.date) - guard let year = components.year, let month = components.month else { continue } - - let key = "\(year)-\(month)" - monthlyTotals[key, default: 0] += dataPoint.value - } + // Initialize aggregator with the local calendar + self.aggregator = StatsDataAggregator(calendar: localCalendar) - // Second pass: create MonthItems with beginning-of-month dates - for (key, value) in monthlyTotals { - let parts = key.split(separator: "-") - guard parts.count == 2, - let year = Int(parts[0]), - let month = Int(parts[1]) else { continue } - - // Create date at beginning of month - var dateComponents = DateComponents() - dateComponents.year = year - dateComponents.month = month - dateComponents.day = 1 - - guard let monthDate = localCalendar.date(from: dateComponents) else { continue } + // Use StatsDataAggregator to aggregate data by month + let aggregatedData = aggregator.aggregate(dataPoints, granularity: .month) + let normalizedData = aggregator.normalizeForMetric(aggregatedData, metric: metric) + + // Process normalized data into year -> month -> DataPoint structure + var monthlyData: [Int: [Int: DataPoint]] = [:] + var maxMonthlyViews = 0 + + for (date, value) in normalizedData { + let components = localCalendar.dateComponents([.year, .month], from: date) + guard let year = components.year, let month = components.month else { continue } if monthlyData[year] == nil { monthlyData[year] = [:] } - let monthItem = MonthItem(date: monthDate, value: value) - monthlyData[year]?[month] = monthItem + let dataPoint = DataPoint(date: date, value: value) + monthlyData[year]?[month] = dataPoint // Track max monthly value maxMonthlyViews = max(maxMonthlyViews, value) @@ -163,13 +145,13 @@ final class YearlyTrendsViewModel: ObservableObject { self.maxMonthlyViews = max(maxMonthlyViews, 1) // Avoid division by zero } - func getMonthlyData(for year: Int) -> [MonthItem?] { - var monthlyItems: [MonthItem?] = Array(repeating: nil, count: 12) + func getMonthlyData(for year: Int) -> [DataPoint?] { + var monthlyItems: [DataPoint?] = Array(repeating: nil, count: 12) if let yearData = monthlyData[year] { - for (month, item) in yearData { + for (month, dataPoint) in yearData { if month >= 1 && month <= 12 { - monthlyItems[month - 1] = item + monthlyItems[month - 1] = dataPoint } } } @@ -183,7 +165,7 @@ final class YearlyTrendsViewModel: ObservableObject { } private struct MonthCell: View { - let monthItem: YearlyTrendsViewModel.MonthItem + let dataPoint: DataPoint let metric: SiteMetric let maxValue: Int let formatter: YearlyTrendsViewModel @@ -192,7 +174,7 @@ private struct MonthCell: View { var body: some View { HeatmapCellView( - value: monthItem.value, + value: dataPoint.value, metric: metric, maxValue: maxValue ) @@ -201,8 +183,8 @@ private struct MonthCell: View { } .popover(isPresented: $showingPopover) { MonthlyTrendsTooltipView( - date: monthItem.date, - value: monthItem.value, + date: dataPoint.date, + value: dataPoint.value, metric: metric, formatter: formatter ) @@ -216,8 +198,8 @@ private struct MonthCell: View { private var accessibilityLabel: String { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "MMMM yyyy" - let dateString = dateFormatter.string(from: monthItem.date) - return "\(dateString), \(formatter.formatValue(monthItem.value)) \(metric.localizedTitle)" + let dateString = dateFormatter.string(from: dataPoint.date) + return "\(dateString), \(formatter.formatValue(dataPoint.value)) \(metric.localizedTitle)" } } From 8acd3d604118e907b14ba109a1d1a2eec6735e7e Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Thu, 24 Jul 2025 12:34:29 -0400 Subject: [PATCH 043/349] Refactor StatsDataAggregator --- .../Services/Mocks/StatsDataAggregator.swift | 67 ++++------ .../JetpackStats/Views/YearlyTrendsView.swift | 3 +- .../StatsDataAggregationTests.swift | 119 +++++++----------- .../TrendViewModelTests.swift | 1 - 4 files changed, 66 insertions(+), 124 deletions(-) diff --git a/Modules/Sources/JetpackStats/Services/Mocks/StatsDataAggregator.swift b/Modules/Sources/JetpackStats/Services/Mocks/StatsDataAggregator.swift index 8d5a1cbeb9bc..fad6f4adef75 100644 --- a/Modules/Sources/JetpackStats/Services/Mocks/StatsDataAggregator.swift +++ b/Modules/Sources/JetpackStats/Services/Mocks/StatsDataAggregator.swift @@ -21,27 +21,29 @@ struct AggregatedDataPoint { /// Date("2025-01-16T15:10:00Z"): 180 /// ] /// -/// // Aggregate by day -/// let aggregated = aggregator.aggregate(hourlyData, granularity: .day) +/// // Aggregate by day with normalization for views (sum strategy) +/// let dailyViews = aggregator.aggregate(hourlyData, granularity: .day, metric: .views) /// // Result: [ -/// // Date("2025-01-15T00:00:00Z"): AggregatedDataPoint(sum: 470, count: 3), -/// // Date("2025-01-16T00:00:00Z"): AggregatedDataPoint(sum: 480, count: 2) +/// // Date("2025-01-15T00:00:00Z"): 470, // sum of all views +/// // Date("2025-01-16T00:00:00Z"): 480 // sum of all views /// // ] /// -/// // Normalize for averaged metrics (e.g., bounce rate) -/// let normalized = aggregator.normalizeForMetric(aggregated, metric: .bounceRate) +/// // Aggregate by day with normalization for bounce rate (average strategy) +/// let dailyBounceRate = aggregator.aggregate(hourlyData, granularity: .day, metric: .bounceRate) /// // Result: [ -/// // Date("2025-01-15T00:00:00Z"): 156, // 470/3 -/// // Date("2025-01-16T00:00:00Z"): 240 // 480/2 +/// // Date("2025-01-15T00:00:00Z"): 156, // 470/3 (average) +/// // Date("2025-01-16T00:00:00Z"): 240 // 480/2 (average) /// // ] /// ``` struct StatsDataAggregator { var calendar: Calendar - /// Aggregates data points based on the given granularity. - func aggregate(_ dataPoints: [DataPoint], granularity: DateRangeGranularity) -> [Date: AggregatedDataPoint] { + /// Aggregates data points based on the given granularity and normalizes for the specified metric. + /// This combines the previous aggregate and normalizeForMetric functions for efficiency. + func aggregate(_ dataPoints: [DataPoint], granularity: DateRangeGranularity, metric: SiteMetric) -> [Date: Int] { var aggregatedData: [Date: AggregatedDataPoint] = [:] + // First pass: aggregate data for dataPoint in dataPoints { if let aggregatedDate = makeAggegationDate(for: dataPoint.date, granularity: granularity) { let existing = aggregatedData[aggregatedDate] @@ -52,39 +54,8 @@ struct StatsDataAggregator { } } - return aggregatedData - } - - func makeAggegationDate(for date: Date, granularity: DateRangeGranularity) -> Date? { - let dateComponents = calendar.dateComponents(granularity.calendarComponents, from: date) - return calendar.date(from: dateComponents) - } - - /// Aggregates data based on the given granularity. - func aggregate(_ data: [Date: Int], granularity: DateRangeGranularity) -> [Date: AggregatedDataPoint] { - return aggregateByComponents(data, components: granularity.calendarComponents) - } - - /// Aggregates data by specified calendar components. - func aggregateByComponents(_ data: [Date: Int], components: Set) -> [Date: AggregatedDataPoint] { - var aggregatedData: [Date: AggregatedDataPoint] = [:] - for (date, value) in data { - let dateComponents = calendar.dateComponents(components, from: date) - if let aggregatedDate = calendar.date(from: dateComponents) { - let existing = aggregatedData[aggregatedDate] - aggregatedData[aggregatedDate] = AggregatedDataPoint( - sum: (existing?.sum ?? 0) + value, - count: (existing?.count ?? 0) + 1 - ) - } - } - return aggregatedData - } - - /// Normalizes data for metrics that need averaging (timeOnSite, bounceRate). - func normalizeForMetric(_ aggregatedData: [Date: AggregatedDataPoint], metric: SiteMetric) -> [Date: Int] { + // Second pass: normalize based on metric strategy var normalizedData: [Date: Int] = [:] - for (date, dataPoint) in aggregatedData { switch metric.aggregarionStrategy { case .sum: @@ -99,7 +70,12 @@ struct StatsDataAggregator { return normalizedData } - /// Generates sequence of dates between start and end. + private func makeAggegationDate(for date: Date, granularity: DateRangeGranularity) -> Date? { + let dateComponents = calendar.dateComponents(granularity.calendarComponents, from: date) + return calendar.date(from: dateComponents) + } + + /// Generates sequence of dates between start and end with the given component. func generateDateSequence(dateInterval: DateInterval, by component: Calendar.Component, value: Int = 1) -> [Date] { var dates: [Date] = [] var currentDate = dateInterval.start @@ -125,9 +101,8 @@ struct StatsDataAggregator { granularity: DateRangeGranularity, metric: SiteMetric ) -> PeriodData { - // Aggregate data - let aggregatedData = aggregate(dataPoints, granularity: granularity) - let normalizedData = normalizeForMetric(aggregatedData, metric: metric) + // Aggregate and normalize data in one pass + let normalizedData = aggregate(dataPoints, granularity: granularity, metric: metric) // Generate complete date sequence for the range let dateSequence = generateDateSequence(dateInterval: dateInterval, by: granularity.component) diff --git a/Modules/Sources/JetpackStats/Views/YearlyTrendsView.swift b/Modules/Sources/JetpackStats/Views/YearlyTrendsView.swift index 4b0a0d2258de..9934814fbf0f 100644 --- a/Modules/Sources/JetpackStats/Views/YearlyTrendsView.swift +++ b/Modules/Sources/JetpackStats/Views/YearlyTrendsView.swift @@ -118,8 +118,7 @@ final class YearlyTrendsViewModel: ObservableObject { self.aggregator = StatsDataAggregator(calendar: localCalendar) // Use StatsDataAggregator to aggregate data by month - let aggregatedData = aggregator.aggregate(dataPoints, granularity: .month) - let normalizedData = aggregator.normalizeForMetric(aggregatedData, metric: metric) + let normalizedData = aggregator.aggregate(dataPoints, granularity: .month, metric: metric) // Process normalized data into year -> month -> DataPoint structure var monthlyData: [Int: [Int: DataPoint]] = [:] diff --git a/Modules/Tests/JetpackStatsTests/StatsDataAggregationTests.swift b/Modules/Tests/JetpackStatsTests/StatsDataAggregationTests.swift index 3637d7cd5995..c7bb509311c9 100644 --- a/Modules/Tests/JetpackStatsTests/StatsDataAggregationTests.swift +++ b/Modules/Tests/JetpackStatsTests/StatsDataAggregationTests.swift @@ -16,27 +16,25 @@ struct StatsDataAggregationTests { let date3 = Date("2025-01-15T14:45:00Z") let date4 = Date("2025-01-15T15:10:00Z") - let testData: [Date: Int] = [ - date1: 100, - date2: 200, - date3: 150, - date4: 300 + let testData = [ + DataPoint(date: date1, value: 100), + DataPoint(date: date2, value: 200), + DataPoint(date: date3, value: 150), + DataPoint(date: date4, value: 300) ] - let aggregated = aggregator.aggregate(testData, granularity: .hour) + let aggregated = aggregator.aggregate(testData, granularity: .hour, metric: .views) // Should have 2 hours worth of data #expect(aggregated.count == 2) // Check hour 14:00 let hour14 = Date("2025-01-15T14:00:00Z") - #expect(aggregated[hour14]?.sum == 450) // 100 + 200 + 150 - #expect(aggregated[hour14]?.count == 3) + #expect(aggregated[hour14] == 450) // 100 + 200 + 150 // Check hour 15:00 let hour15 = Date("2025-01-15T15:00:00Z") - #expect(aggregated[hour15]?.sum == 300) - #expect(aggregated[hour15]?.count == 1) + #expect(aggregated[hour15] == 300) } @Test @@ -44,67 +42,63 @@ struct StatsDataAggregationTests { let aggregator = StatsDataAggregator(calendar: calendar) // Create test data across multiple days - let testData: [Date: Int] = [ - Date("2025-01-15T08:00:00Z"): 100, - Date("2025-01-15T14:00:00Z"): 200, - Date("2025-01-15T20:00:00Z"): 150, - Date("2025-01-16T10:00:00Z"): 300 + let testData = [ + DataPoint(date: Date("2025-01-15T08:00:00Z"), value: 100), + DataPoint(date: Date("2025-01-15T14:00:00Z"), value: 200), + DataPoint(date: Date("2025-01-15T20:00:00Z"), value: 150), + DataPoint(date: Date("2025-01-16T10:00:00Z"), value: 300) ] - let aggregated = aggregator.aggregate(testData, granularity: .day) + let aggregated = aggregator.aggregate(testData, granularity: .day, metric: .views) #expect(aggregated.count == 2) let day1 = Date("2025-01-15T00:00:00Z") let day2 = Date("2025-01-16T00:00:00Z") - #expect(aggregated[day1]?.sum == 450) - #expect(aggregated[day1]?.count == 3) - #expect(aggregated[day2]?.sum == 300) - #expect(aggregated[day2]?.count == 1) + #expect(aggregated[day1] == 450) + #expect(aggregated[day2] == 300) } @Test func monthlyAggregation() { let aggregator = StatsDataAggregator(calendar: calendar) - let testData: [Date: Int] = [ - Date("2025-01-15T08:00:00Z"): 100, - Date("2025-01-20T14:00:00Z"): 200, - Date("2025-02-10T10:00:00Z"): 300 + let testData = [ + DataPoint(date: Date("2025-01-15T08:00:00Z"), value: 100), + DataPoint(date: Date("2025-01-20T14:00:00Z"), value: 200), + DataPoint(date: Date("2025-02-10T10:00:00Z"), value: 300) ] - let aggregated = aggregator.aggregate(testData, granularity: .month) + let aggregated = aggregator.aggregate(testData, granularity: .month, metric: .views) #expect(aggregated.count == 2) let jan = Date("2025-01-01T00:00:00Z") let feb = Date("2025-02-01T00:00:00Z") - #expect(aggregated[jan]?.sum == 300) - #expect(aggregated[jan]?.count == 2) - #expect(aggregated[feb]?.sum == 300) - #expect(aggregated[feb]?.count == 1) + #expect(aggregated[jan] == 300) + #expect(aggregated[feb] == 300) } @Test func yearlyAggregation() { let aggregator = StatsDataAggregator(calendar: calendar) - let testData: [Date: Int] = [ - Date("2025-01-15T08:00:00Z"): 100, - Date("2025-03-20T14:00:00Z"): 200, - Date("2025-05-10T10:00:00Z"): 300 + let testData = [ + DataPoint(date: Date("2025-01-15T08:00:00Z"), value: 100), + DataPoint(date: Date("2025-03-20T14:00:00Z"), value: 200), + DataPoint(date: Date("2025-05-10T10:00:00Z"), value: 300) ] - let aggregated = aggregator.aggregate(testData, granularity: .year) + let aggregated = aggregator.aggregate(testData, granularity: .year, metric: .views) // Year granularity aggregates by month #expect(aggregated.count == 1) let jan = Date("2025-01-01T00:00:00Z") - #expect(aggregated[jan]?.sum == 600) + #expect(aggregated[jan] == 600) } // MARK: - Date Sequence Generation Tests @@ -196,56 +190,31 @@ struct StatsDataAggregationTests { #expect(sequence[2] == Date("2025-01-17T14:30:00Z")) } - // MARK: - Aggregation Helper Tests + // MARK: - Averaged Metrics Tests @Test - func aggregateByComponents() { + func aggregateWithAveragedMetric() { let aggregator = StatsDataAggregator(calendar: calendar) - let testData: [Date: Int] = [ - Date("2025-01-15T14:15:00Z"): 100, - Date("2025-01-15T14:30:00Z"): 200, - Date("2025-01-15T15:10:00Z"): 300 + let testData = [ + DataPoint(date: Date("2025-01-15T08:00:00Z"), value: 300), + DataPoint(date: Date("2025-01-15T14:00:00Z"), value: 600), + DataPoint(date: Date("2025-01-15T20:00:00Z"), value: 900), + DataPoint(date: Date("2025-01-16T10:00:00Z"), value: 400) ] - let aggregated = aggregator.aggregateByComponents( - testData, - components: [Calendar.Component.year, Calendar.Component.month, Calendar.Component.day, Calendar.Component.hour] - ) + // Test with timeOnSite metric which uses average strategy + let aggregated = aggregator.aggregate(testData, granularity: .day, metric: .timeOnSite) #expect(aggregated.count == 2) - } - - @Test - func normalizeForMetric_regularMetrics() { - let aggregator = StatsDataAggregator(calendar: calendar) - - let aggregatedData: [Date: AggregatedDataPoint] = [ - Date("2025-01-15T00:00:00Z"): AggregatedDataPoint(sum: 600, count: 3), - Date("2025-01-16T00:00:00Z"): AggregatedDataPoint(sum: 900, count: 3) - ] - - // For regular metrics (views, visitors, etc), values should not change - let normalized = aggregator.normalizeForMetric(aggregatedData, metric: SiteMetric.views) - #expect(normalized[Date("2025-01-15T00:00:00Z")] == 600) - #expect(normalized[Date("2025-01-16T00:00:00Z")] == 900) - } - - @Test - func normalizeForMetric_averagedMetrics() { - let aggregator = StatsDataAggregator(calendar: calendar) - - let aggregatedData: [Date: AggregatedDataPoint] = [ - Date("2025-01-15T00:00:00Z"): AggregatedDataPoint(sum: 600, count: 3), - Date("2025-01-16T00:00:00Z"): AggregatedDataPoint(sum: 900, count: 3) - ] - - // For timeOnSite and bounceRate, values should be averaged - let normalized = aggregator.normalizeForMetric(aggregatedData, metric: SiteMetric.timeOnSite) + let day1 = Date("2025-01-15T00:00:00Z") + let day2 = Date("2025-01-16T00:00:00Z") - #expect(normalized[Date("2025-01-15T00:00:00Z")] == 200) // 600/3 - #expect(normalized[Date("2025-01-16T00:00:00Z")] == 300) // 900/3 + // Values should be averaged: (300 + 600 + 900) / 3 = 600 + #expect(aggregated[day1] == 600) + // Single value: 400 / 1 = 400 + #expect(aggregated[day2] == 400) } // MARK: - Process Period Tests diff --git a/Modules/Tests/JetpackStatsTests/TrendViewModelTests.swift b/Modules/Tests/JetpackStatsTests/TrendViewModelTests.swift index 8684d430cc9c..1015f4a41078 100644 --- a/Modules/Tests/JetpackStatsTests/TrendViewModelTests.swift +++ b/Modules/Tests/JetpackStatsTests/TrendViewModelTests.swift @@ -122,7 +122,6 @@ struct TrendViewModelTests { @Test("Formatted percentage string", arguments: [ (150, 100, "50%"), (175, 100, "75%"), - (1000, 1, "1K%"), (100, 100, "0%"), (125, 100, "25%"), (100, 0, "∞") From 167234717941d968c15cdf2c92ef9d2b17af7dd1 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Thu, 24 Jul 2025 12:41:36 -0400 Subject: [PATCH 044/349] Refactor YearlyTrendsViewModel --- .../Screens/PostStatsDetailsView.swift | 3 +- .../JetpackStats/Views/YearlyTrendsView.swift | 100 +++++++++--------- 2 files changed, 52 insertions(+), 51 deletions(-) diff --git a/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift b/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift index 79fe459f64da..45090dc2db7d 100644 --- a/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift +++ b/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift @@ -79,8 +79,7 @@ struct PostStatsDetailsView: View { YearlyTrendsView( viewModel: YearlyTrendsViewModel( dataPoints: dataPoints, - calendar: context.calendar, - timeZone: context.timeZone + calendar: context.calendar ) ) } diff --git a/Modules/Sources/JetpackStats/Views/YearlyTrendsView.swift b/Modules/Sources/JetpackStats/Views/YearlyTrendsView.swift index 9934814fbf0f..ca09f873349f 100644 --- a/Modules/Sources/JetpackStats/Views/YearlyTrendsView.swift +++ b/Modules/Sources/JetpackStats/Views/YearlyTrendsView.swift @@ -41,16 +41,9 @@ struct YearlyTrendsView: View { HStack(spacing: cellSpacing) { ForEach(6..<12) { index in - if let dataPoint = monthlyData[index] { - monthCell(dataPoint: dataPoint) - .frame(maxWidth: .infinity) - .aspectRatio(1, contentMode: .fit) - } else { - // Empty cell for months with no data - Color.clear - .frame(maxWidth: .infinity) - .aspectRatio(1, contentMode: .fit) - } + monthCell(dataPoint: monthlyData[index]) + .frame(maxWidth: .infinity) + .aspectRatio(1, contentMode: .fit) } } } @@ -62,16 +55,9 @@ struct YearlyTrendsView: View { HStack(spacing: cellSpacing) { ForEach(0..<6) { index in - if let dataPoint = monthlyData[index] { - monthCell(dataPoint: dataPoint) - .frame(maxWidth: .infinity) - .aspectRatio(1, contentMode: .fit) - } else { - // Empty cell for months with no data - Color.clear - .frame(maxWidth: .infinity) - .aspectRatio(1, contentMode: .fit) - } + monthCell(dataPoint: monthlyData[index]) + .frame(maxWidth: .infinity) + .aspectRatio(1, contentMode: .fit) } } } @@ -97,43 +83,66 @@ struct YearlyTrendsView: View { final class YearlyTrendsViewModel: ObservableObject { let metric: SiteMetric + private let calendar: Calendar private let valueFormatter: StatsValueFormatter private let aggregator: StatsDataAggregator let sortedYears: [Int] let maxMonthlyViews: Int - private var monthlyData: [Int: [Int: DataPoint]] = [:] // year -> month -> DataPoint + private var monthlyData: [Int: [DataPoint]] = [:] // year -> array of 12 DataPoints (Jan=0, Dec=11) - init(dataPoints: [DataPoint], calendar: Calendar, timeZone: TimeZone, metric: SiteMetric = .views) { + init(dataPoints: [DataPoint], calendar: Calendar, metric: SiteMetric = .views) { self.metric = metric + self.calendar = calendar self.valueFormatter = StatsValueFormatter(metric: metric) - // Configure calendar with the correct time zone - var localCalendar = calendar - localCalendar.timeZone = timeZone - - // Initialize aggregator with the local calendar - self.aggregator = StatsDataAggregator(calendar: localCalendar) + // Initialize aggregator with the calendar + self.aggregator = StatsDataAggregator(calendar: calendar) // Use StatsDataAggregator to aggregate data by month let normalizedData = aggregator.aggregate(dataPoints, granularity: .month, metric: metric) - // Process normalized data into year -> month -> DataPoint structure - var monthlyData: [Int: [Int: DataPoint]] = [:] + // Process normalized data into year -> array of 12 months structure + var monthlyData: [Int: [DataPoint]] = [:] var maxMonthlyViews = 0 - for (date, value) in normalizedData { - let components = localCalendar.dateComponents([.year, .month], from: date) - guard let year = components.year, let month = components.month else { continue } + // First, collect all years that have data + var yearsWithData = Set() + for (date, _) in normalizedData { + let components = calendar.dateComponents([.year], from: date) + if let year = components.year { + yearsWithData.insert(year) + } + } + + // Initialize arrays with empty DataPoints for each year + for year in yearsWithData { + var yearData: [DataPoint] = [] - if monthlyData[year] == nil { - monthlyData[year] = [:] + // Create DataPoint for each month + for month in 1...12 { + var dateComponents = DateComponents() + dateComponents.year = year + dateComponents.month = month + dateComponents.day = 1 + + if let monthDate = calendar.date(from: dateComponents) { + yearData.append(DataPoint(date: monthDate, value: 0)) + } } - let dataPoint = DataPoint(date: date, value: value) - monthlyData[year]?[month] = dataPoint + monthlyData[year] = yearData + } + + // Fill in actual values + for (date, value) in normalizedData { + let components = calendar.dateComponents([.year, .month], from: date) + guard let year = components.year, let month = components.month, month >= 1 && month <= 12 else { continue } + + // Update the DataPoint with the actual value + monthlyData[year]?[month - 1] = DataPoint(date: date, value: value) // Track max monthly value maxMonthlyViews = max(maxMonthlyViews, value) @@ -144,18 +153,12 @@ final class YearlyTrendsViewModel: ObservableObject { self.maxMonthlyViews = max(maxMonthlyViews, 1) // Avoid division by zero } - func getMonthlyData(for year: Int) -> [DataPoint?] { - var monthlyItems: [DataPoint?] = Array(repeating: nil, count: 12) - - if let yearData = monthlyData[year] { - for (month, dataPoint) in yearData { - if month >= 1 && month <= 12 { - monthlyItems[month - 1] = dataPoint - } - } + func getMonthlyData(for year: Int) -> [DataPoint] { + guard let yearData = monthlyData[year] else { + assertionFailure() + return [] } - - return monthlyItems + return yearData } func formatValue(_ value: Int) -> String { @@ -247,7 +250,6 @@ private struct MonthlyTrendsTooltipView: View { viewModel: YearlyTrendsViewModel( dataPoints: mockDataPoints(), calendar: Calendar.current, - timeZone: TimeZone.current, metric: .views ) ) From 7e868d480f87fdb2f05d312b5f1c23a35a39d369 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Thu, 24 Jul 2025 12:49:50 -0400 Subject: [PATCH 045/349] Add YearlyTrendsViewModelTests --- .../JetpackStats/Views/WeeklyTrendsView.swift | 2 +- .../JetpackStats/Views/YearlyTrendsView.swift | 37 ++- .../YearlyTrendsViewModelTests.swift | 237 ++++++++++++++++++ 3 files changed, 252 insertions(+), 24 deletions(-) create mode 100644 Modules/Tests/JetpackStatsTests/YearlyTrendsViewModelTests.swift diff --git a/Modules/Sources/JetpackStats/Views/WeeklyTrendsView.swift b/Modules/Sources/JetpackStats/Views/WeeklyTrendsView.swift index 85f7d8ae9239..a5d579332609 100644 --- a/Modules/Sources/JetpackStats/Views/WeeklyTrendsView.swift +++ b/Modules/Sources/JetpackStats/Views/WeeklyTrendsView.swift @@ -5,7 +5,7 @@ struct WeeklyTrendsView: View { let viewModel: WeeklyTrendsViewModel private let cellSpacing: CGFloat = 4 - private let weekLabelWidth: CGFloat = 36 + private let weekLabelWidth: CGFloat = 40 @State private var selectedDay: Week.Day? @State private var selectedWeek: Week? diff --git a/Modules/Sources/JetpackStats/Views/YearlyTrendsView.swift b/Modules/Sources/JetpackStats/Views/YearlyTrendsView.swift index ca09f873349f..54685cefb6a6 100644 --- a/Modules/Sources/JetpackStats/Views/YearlyTrendsView.swift +++ b/Modules/Sources/JetpackStats/Views/YearlyTrendsView.swift @@ -5,7 +5,7 @@ struct YearlyTrendsView: View { let viewModel: YearlyTrendsViewModel private let cellSpacing: CGFloat = 6 - private let yearLabelWidth: CGFloat = 36 + private let yearLabelWidth: CGFloat = 40 init(viewModel: YearlyTrendsViewModel) { self.viewModel = viewModel @@ -30,15 +30,14 @@ struct YearlyTrendsView: View { @ViewBuilder private func yearRow(for year: Int) -> some View { let monthlyData = viewModel.getMonthlyData(for: year) - - VStack(spacing: cellSpacing) { - // First row: Jul-Dec (top) - HStack(spacing: 8) { - Text(String(year)) - .font(.caption) - .foregroundColor(.secondary) - .frame(width: yearLabelWidth, alignment: .trailing) - + + HStack(spacing: 8) { + Text(String(year)) + .font(.caption) + .foregroundColor(.secondary) + .frame(width: yearLabelWidth, alignment: .trailing) + VStack(spacing: cellSpacing) { + // First row: Jul-Dec (top) HStack(spacing: cellSpacing) { ForEach(6..<12) { index in monthCell(dataPoint: monthlyData[index]) @@ -46,13 +45,7 @@ struct YearlyTrendsView: View { .aspectRatio(1, contentMode: .fit) } } - } - - // Second row: Jan-Jun (bottom) - HStack(spacing: 8) { - Color.clear - .frame(width: yearLabelWidth) - + // Second row: Jan-Jun (bottom) HStack(spacing: cellSpacing) { ForEach(0..<6) { index in monthCell(dataPoint: monthlyData[index]) @@ -63,7 +56,7 @@ struct YearlyTrendsView: View { } } } - + @ViewBuilder private func monthCell(dataPoint: DataPoint) -> some View { MonthCell( @@ -73,7 +66,7 @@ struct YearlyTrendsView: View { formatter: viewModel ) } - + private var legend: some View { HeatmapLegendView(metric: viewModel.metric, labelWidth: yearLabelWidth) } @@ -85,8 +78,7 @@ final class YearlyTrendsViewModel: ObservableObject { private let calendar: Calendar private let valueFormatter: StatsValueFormatter - private let aggregator: StatsDataAggregator - + let sortedYears: [Int] let maxMonthlyViews: Int @@ -99,7 +91,7 @@ final class YearlyTrendsViewModel: ObservableObject { self.valueFormatter = StatsValueFormatter(metric: metric) // Initialize aggregator with the calendar - self.aggregator = StatsDataAggregator(calendar: calendar) + let aggregator = StatsDataAggregator(calendar: calendar) // Use StatsDataAggregator to aggregate data by month let normalizedData = aggregator.aggregate(dataPoints, granularity: .month, metric: metric) @@ -155,7 +147,6 @@ final class YearlyTrendsViewModel: ObservableObject { func getMonthlyData(for year: Int) -> [DataPoint] { guard let yearData = monthlyData[year] else { - assertionFailure() return [] } return yearData diff --git a/Modules/Tests/JetpackStatsTests/YearlyTrendsViewModelTests.swift b/Modules/Tests/JetpackStatsTests/YearlyTrendsViewModelTests.swift new file mode 100644 index 000000000000..2614f9c8b88c --- /dev/null +++ b/Modules/Tests/JetpackStatsTests/YearlyTrendsViewModelTests.swift @@ -0,0 +1,237 @@ +import Testing +import Foundation +@testable import JetpackStats + +@MainActor @Suite +struct YearlyTrendsViewModelTests { + let calendar = Calendar.mock(timeZone: TimeZone(secondsFromGMT: 0)!) + + @Test + func initWithEmptyDataPoints() { + let viewModel = YearlyTrendsViewModel( + dataPoints: [], + calendar: calendar + ) + + #expect(viewModel.sortedYears.isEmpty) + #expect(viewModel.maxMonthlyViews == 1) // Should default to 1 to avoid division by zero + } + + @Test + func initWithSingleMonthData() { + let dataPoints = [ + DataPoint(date: Date("2025-01-15T10:00:00Z"), value: 100), + DataPoint(date: Date("2025-01-20T10:00:00Z"), value: 200) + ] + + let viewModel = YearlyTrendsViewModel( + dataPoints: dataPoints, + calendar: calendar + ) + + #expect(viewModel.sortedYears == [2025]) + #expect(viewModel.maxMonthlyViews == 300) // Sum of January values + + let monthlyData = viewModel.getMonthlyData(for: 2025) + #expect(monthlyData.count == 12) + + // January should have the sum of values + #expect(monthlyData[0].value == 300) + + // Other months should have 0 + for month in 1..<12 { + #expect(monthlyData[month].value == 0) + } + } + + @Test + func initWithMultipleMonthsData() { + let dataPoints = [ + // January data + DataPoint(date: Date("2025-01-15T10:00:00Z"), value: 100), + DataPoint(date: Date("2025-01-20T10:00:00Z"), value: 200), + // March data + DataPoint(date: Date("2025-03-10T10:00:00Z"), value: 400), + // December data + DataPoint(date: Date("2025-12-25T10:00:00Z"), value: 500) + ] + + let viewModel = YearlyTrendsViewModel( + dataPoints: dataPoints, + calendar: calendar + ) + + #expect(viewModel.maxMonthlyViews == 500) // December has the highest value + + let monthlyData = viewModel.getMonthlyData(for: 2025) + #expect(monthlyData[0].value == 300) // January + #expect(monthlyData[1].value == 0) // February + #expect(monthlyData[2].value == 400) // March + #expect(monthlyData[11].value == 500) // December + } + + @Test + func initWithMultipleYearsData() { + let dataPoints = [ + // 2024 data + DataPoint(date: Date("2024-06-15T10:00:00Z"), value: 100), + DataPoint(date: Date("2024-12-15T10:00:00Z"), value: 200), + // 2025 data + DataPoint(date: Date("2025-01-15T10:00:00Z"), value: 300), + DataPoint(date: Date("2025-03-15T10:00:00Z"), value: 400), + // 2023 data + DataPoint(date: Date("2023-09-15T10:00:00Z"), value: 150) + ] + + let viewModel = YearlyTrendsViewModel( + dataPoints: dataPoints, + calendar: calendar + ) + + // Years should be sorted in descending order + #expect(viewModel.sortedYears == [2025, 2024, 2023]) + #expect(viewModel.maxMonthlyViews == 400) // March 2025 has the highest + + // Check 2025 data + let data2025 = viewModel.getMonthlyData(for: 2025) + #expect(data2025[0].value == 300) // January + #expect(data2025[2].value == 400) // March + + // Check 2024 data + let data2024 = viewModel.getMonthlyData(for: 2024) + #expect(data2024[5].value == 100) // June + #expect(data2024[11].value == 200) // December + + // Check 2023 data + let data2023 = viewModel.getMonthlyData(for: 2023) + #expect(data2023[8].value == 150) // September + } + + @Test + func monthlyDataDatesAreCorrect() { + let dataPoints = [ + DataPoint(date: Date("2025-01-15T10:00:00Z"), value: 100) + ] + + let viewModel = YearlyTrendsViewModel( + dataPoints: dataPoints, + calendar: calendar + ) + + let monthlyData = viewModel.getMonthlyData(for: 2025) + + // Check that each month has the correct date (1st of each month) + for (index, dataPoint) in monthlyData.enumerated() { + let components = calendar.dateComponents([.year, .month, .day], from: dataPoint.date) + #expect(components.year == 2025) + #expect(components.month == index + 1) + #expect(components.day == 1) + } + } + + @Test + func aggregationWithDifferentMetrics() { + let dataPoints = [ + // Multiple values in same month to test aggregation + DataPoint(date: Date("2025-01-10T10:00:00Z"), value: 300), + DataPoint(date: Date("2025-01-15T10:00:00Z"), value: 600), + DataPoint(date: Date("2025-01-20T10:00:00Z"), value: 900) + ] + + // Test with views (sum strategy) + let viewsViewModel = YearlyTrendsViewModel( + dataPoints: dataPoints, + calendar: calendar, + metric: .views + ) + + let viewsData = viewsViewModel.getMonthlyData(for: 2025) + #expect(viewsData[0].value == 1800) // Sum: 300 + 600 + 900 + + // Test with timeOnSite (average strategy) + let timeViewModel = YearlyTrendsViewModel( + dataPoints: dataPoints, + calendar: calendar, + metric: .timeOnSite + ) + + let timeData = timeViewModel.getMonthlyData(for: 2025) + #expect(timeData[0].value == 600) // Average: (300 + 600 + 900) / 3 + } + + @Test + func formatValue() { + let dataPoints = [ + DataPoint(date: Date("2025-01-15T10:00:00Z"), value: 1234) + ] + + let viewModel = YearlyTrendsViewModel( + dataPoints: dataPoints, + calendar: calendar + ) + + #expect(viewModel.formatValue(1234) == "1.2K") + #expect(viewModel.formatValue(0) == "0") + #expect(viewModel.formatValue(999) == "999") + #expect(viewModel.formatValue(1000) == "1K") + } + + @Test + func getMonthlyDataForNonExistentYear() { + let dataPoints = [ + DataPoint(date: Date("2025-01-15T10:00:00Z"), value: 100) + ] + + let viewModel = YearlyTrendsViewModel( + dataPoints: dataPoints, + calendar: calendar + ) + + // This should return empty array after the assertionFailure + let data = viewModel.getMonthlyData(for: 2024) + #expect(data.isEmpty) + } + + @Test + func handlesLeapYearCorrectly() { + // Test with February data in leap year + let dataPoints = [ + DataPoint(date: Date("2024-02-29T10:00:00Z"), value: 100) // 2024 is a leap year + ] + + let viewModel = YearlyTrendsViewModel( + dataPoints: dataPoints, + calendar: calendar + ) + + let monthlyData = viewModel.getMonthlyData(for: 2024) + #expect(monthlyData[1].value == 100) // February + + // Verify the date is set to Feb 1st + let febComponents = calendar.dateComponents([.year, .month, .day], from: monthlyData[1].date) + #expect(febComponents.year == 2024) + #expect(febComponents.month == 2) + #expect(febComponents.day == 1) + } + + @Test + func handlesTimeZoneCorrectly() { + // Create calendar with different timezone + let pstCalendar = Calendar.mock(timeZone: TimeZone(identifier: "America/Los_Angeles")!) + + // This date is late evening PST, which is next day in UTC + let dataPoints = [ + DataPoint(date: Date("2025-01-31T23:00:00-08:00"), value: 100) // Jan 31, 11 PM PST = Feb 1 UTC + ] + + let viewModel = YearlyTrendsViewModel( + dataPoints: dataPoints, + calendar: pstCalendar + ) + + let monthlyData = viewModel.getMonthlyData(for: 2025) + // In PST timezone, this should still be January + #expect(monthlyData[0].value == 100) // January + #expect(monthlyData[1].value == 0) // February should be empty + } +} From 95a91ae3bc1e9a96523104feb4b9a96806904343 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Thu, 24 Jul 2025 14:26:13 -0400 Subject: [PATCH 046/349] Fix missing componentes --- .../Screens/PostStatsDetailsView.swift | 20 ++++++------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift b/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift index 45090dc2db7d..bce4b0a180ee 100644 --- a/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift +++ b/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift @@ -34,28 +34,20 @@ struct PostStatsDetailsView: View { } } -#warning("TEMP") - @ViewBuilder private var contents: some View { headerView .cardStyle() // // Views Over Time Chart -// if !dataPoints.isEmpty { -// makeChartView(dataPoints: dataPoints) -// } else if isLoading { -// makeChartView(dataPoints: mockDataPoints) -// .redacted(reason: .placeholder) -// } + if !dataPoints.isEmpty { + makeChartView(dataPoints: dataPoints) + } else if isLoading { + makeChartView(dataPoints: mockDataPoints) + .redacted(reason: .placeholder) + } if let details { - // Peak Performance Card -// if details.highestMonth != nil || details.highestDayAverage != nil || details.highestWeekAverage != nil { -// PeakPerformanceCard(details: details) -// .cardStyle() -// } - // Weekly Trends Chart if !details.recentWeeks.isEmpty { VStack(alignment: .leading, spacing: Constants.step2) { From d5fd64c92975ba6ec804fd085189543f19b0fbc0 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Thu, 24 Jul 2025 14:26:23 -0400 Subject: [PATCH 047/349] Fix missing postID --- .../historical-postsAndPages.json | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-postsAndPages.json b/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-postsAndPages.json index 10159bc7b626..878dfd026de1 100644 --- a/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-postsAndPages.json +++ b/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-postsAndPages.json @@ -1,7 +1,7 @@ [ { "title": "Best printer 2025: just buy a Brother laser printer, the winner is clear, middle finger in the air", - "postId": "1", + "postID": "1", "type": "post", "author": "Alex Johnson", "metrics": { @@ -15,7 +15,7 @@ }, { "title": "I Replaced Every Component in My Framework Laptop Until It Became a Desktop", - "postId": "2", + "postID": "2", "type": "post", "author": "Alex Johnson", "metrics": { @@ -29,7 +29,7 @@ }, { "title": "The Cybertruck's Frunk Won't Stop Eating Fingers", - "postId": "3", + "postID": "3", "type": "post", "author": "Chloe Zhang", "metrics": { @@ -43,7 +43,7 @@ }, { "title": "Matter Smart Home Protocol Still Doesn't Matter: A Year Later", - "postId": "4", + "postID": "4", "type": "post", "author": "Jordan Davis", "metrics": { @@ -57,7 +57,7 @@ }, { "title": "Trackpad Alignment on the New MacBook Pro: A 10,000 Word Investigation", - "postId": "5", + "postID": "5", "type": "post", "author": "Sofia Rodriguez", "metrics": { @@ -71,7 +71,7 @@ }, { "title": "Nothing Phone (2a): Now With 50% More Nothing", - "postId": "6", + "postID": "6", "type": "post", "author": "Morgan Smith", "metrics": { @@ -85,7 +85,7 @@ }, { "title": "Samsung's Moon Photography Is Real* (*Terms and Conditions Apply)", - "postId": "7", + "postID": "7", "type": "post", "author": "Emma Thompson", "metrics": { @@ -99,7 +99,7 @@ }, { "title": "The Steam Deck OLED Screen Is So Good I Licked It", - "postId": "8", + "postID": "8", "type": "post", "author": "Riley Martinez", "metrics": { @@ -113,7 +113,7 @@ }, { "title": "Meta's VR Legs Update: They're Still Not Real", - "postId": "9", + "postID": "9", "type": "post", "author": "Riley Martinez", "metrics": { @@ -127,7 +127,7 @@ }, { "title": "About The Verge", - "pageId": "1", + "postID": "9991", "type": "page", "author": "Alex Johnson", "metrics": { @@ -141,7 +141,7 @@ }, { "title": "Microsoft's New AI Can Predict When You'll Rage Quit Excel", - "postId": "10", + "postID": "10", "type": "post", "author": "Jamie Lee", "metrics": { @@ -155,7 +155,7 @@ }, { "title": "I Shattered the Apple Vision Pro's Front Glass and It Only Cost Me $799 to Fix", - "postId": "11", + "postID": "11", "type": "post", "author": "Sofia Rodriguez", "metrics": { @@ -169,7 +169,7 @@ }, { "title": "Google's Pixel 8 Pro Has Too Many Cameras (I Counted)", - "postId": "12", + "postID": "12", "type": "post", "author": "Avery Taylor", "metrics": { @@ -183,7 +183,7 @@ }, { "title": "Contact Us", - "pageId": "2", + "postID": "9992", "type": "page", "author": "Chloe Zhang", "metrics": { @@ -197,7 +197,7 @@ }, { "title": "Newsletter Signup", - "pageId": "3", + "postID": "9993", "type": "page", "author": "Jordan Davis", "metrics": { @@ -211,7 +211,7 @@ }, { "title": "Privacy Policy", - "pageId": "4", + "postID": "9994", "type": "page", "author": "Sofia Rodriguez", "metrics": { @@ -225,7 +225,7 @@ }, { "title": "Terms of Service", - "pageId": "5", + "postID": "9995", "type": "page", "author": "Morgan Smith", "metrics": { From b59e86ece5a6ba25cd041ec7e8e6441448c0702f Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Thu, 24 Jul 2025 14:39:24 -0400 Subject: [PATCH 048/349] Add StatsRouter --- .../Cards/RealtimeTopListCard.swift | 1 + .../JetpackStats/Cards/TopListCard.swift | 4 +- .../Screens/PostStatsDetailsView.swift | 5 ++- .../JetpackStats/Screens/StatsMainView.swift | 28 ++++++++++++- .../Sources/JetpackStats/StatsRouter.swift | 42 +++++++++++++++++++ .../TopList/Rows/TopListPostRowView.swift | 2 +- .../Views/TopList/TopListItemView.swift | 40 +++++++++++++++++- .../Views/TopList/TopListItemsView.swift | 4 +- .../Stats/StatsHostingViewController.swift | 4 ++ 9 files changed, 122 insertions(+), 8 deletions(-) create mode 100644 Modules/Sources/JetpackStats/StatsRouter.swift diff --git a/Modules/Sources/JetpackStats/Cards/RealtimeTopListCard.swift b/Modules/Sources/JetpackStats/Cards/RealtimeTopListCard.swift index 3b2feb768646..4350f1bab7d5 100644 --- a/Modules/Sources/JetpackStats/Cards/RealtimeTopListCard.swift +++ b/Modules/Sources/JetpackStats/Cards/RealtimeTopListCard.swift @@ -105,6 +105,7 @@ struct RealtimeTopListCard: View { return TopListItemsView( data: chartData, itemLimit: 6, + dateRange: context.calendar.makeDateRange(for: .today), showDetails: false ) } diff --git a/Modules/Sources/JetpackStats/Cards/TopListCard.swift b/Modules/Sources/JetpackStats/Cards/TopListCard.swift index 40f75dc6cf0b..81a0f418fcfb 100644 --- a/Modules/Sources/JetpackStats/Cards/TopListCard.swift +++ b/Modules/Sources/JetpackStats/Cards/TopListCard.swift @@ -136,9 +136,9 @@ struct TopListCard: View { VStack(spacing: 0) { ZStack(alignment: .top) { // Ensure consistent sizing - TopListItemsView(data: mockData, itemLimit: itemLimit) + TopListItemsView(data: mockData, itemLimit: itemLimit, dateRange: viewModel.dateRange) .opacity(0) - TopListItemsView(data: data, itemLimit: itemLimit) + TopListItemsView(data: data, itemLimit: itemLimit, dateRange: viewModel.dateRange) } showMoreButton .frame(maxWidth: .infinity, alignment: .leading) diff --git a/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift b/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift index bce4b0a180ee..73c87871839e 100644 --- a/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift +++ b/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift @@ -5,13 +5,14 @@ import WordPressKit struct PostStatsDetailsView: View { let post: TopListData.Post - @Environment(\.context) private var context @State private var details: StatsPostDetails? @State private var postLikes: PostLikesData? @State private var dataPoints: [DataPoint] = [] @State private var isLoading = true @State private var error: Error? + @Environment(\.context) private var context + private let initialDateRange: StatsDateRange init(post: TopListData.Post, dateRange: StatsDateRange) { @@ -39,7 +40,7 @@ struct PostStatsDetailsView: View { headerView .cardStyle() -// // Views Over Time Chart + // Views Over Time Chart if !dataPoints.isEmpty { makeChartView(dataPoints: dataPoints) } else if isLoading { diff --git a/Modules/Sources/JetpackStats/Screens/StatsMainView.swift b/Modules/Sources/JetpackStats/Screens/StatsMainView.swift index 4b5845713f8e..a5544e150337 100644 --- a/Modules/Sources/JetpackStats/Screens/StatsMainView.swift +++ b/Modules/Sources/JetpackStats/Screens/StatsMainView.swift @@ -44,7 +44,33 @@ public struct StatsMainView: View { } #Preview { - NavigationStack { + NavigationPreview { StatsMainView(context: .demo) } + .ignoresSafeArea() +} + +private struct NavigationPreview: UIViewControllerRepresentable { + let content: () -> Content + + init(@ViewBuilder content: @escaping () -> Content) { + self.content = content + } + + func makeUIViewController(context: Context) -> UINavigationController { + let navigationController = UINavigationController() + let router = StatsRouter(navigationController: navigationController) + + let hostingController = UIHostingController( + rootView: content() + .environment(\.router, router) + ) + + navigationController.viewControllers = [hostingController] + return navigationController + } + + func updateUIViewController(_ uiViewController: UINavigationController, context: Context) { + // No update needed + } } diff --git a/Modules/Sources/JetpackStats/StatsRouter.swift b/Modules/Sources/JetpackStats/StatsRouter.swift new file mode 100644 index 000000000000..c61bf2a1c045 --- /dev/null +++ b/Modules/Sources/JetpackStats/StatsRouter.swift @@ -0,0 +1,42 @@ +import SwiftUI +import UIKit + +public struct StatsRouter: Sendable { + public weak var navigationController: UINavigationController? + + public init(navigationController: UINavigationController? = nil) { + self.navigationController = navigationController + } + + @MainActor + func navigate(to destination: StatsDestination) { + guard let navigationController else { return } + + let viewController: UIViewController + + switch destination { + case .postDetails(let post, let dateRange): + let view = PostStatsDetailsView(post: post, dateRange: dateRange) + viewController = UIHostingController(rootView: view) + } + + navigationController.pushViewController(viewController, animated: true) + } +} + +enum StatsDestination { + case postDetails(post: TopListData.Post, dateRange: StatsDateRange) +} + +// MARK: - Environment Key + +private struct StatsRouterKey: EnvironmentKey { + static let defaultValue = StatsRouter() +} + +extension EnvironmentValues { + var router: StatsRouter { + get { self[StatsRouterKey.self] } + set { self[StatsRouterKey.self] = newValue } + } +} diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListPostRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListPostRowView.swift index e3683b7e7da8..58bc6823f322 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListPostRowView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListPostRowView.swift @@ -7,7 +7,7 @@ struct TopListPostRowView: View { var body: some View { VStack(alignment: .leading, spacing: 2) { - ZStack { + ZStack(alignment: .leading) { // Ensure stable height Text(item.title) .lineLimit(2, reservesSpace: true) diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift index 0bbe1a8ab459..39a6d3e70a7e 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift @@ -6,8 +6,24 @@ struct TopListItemView: View { let metric: SiteMetric let maxValue: Int let showDetails: Bool + let dateRange: StatsDateRange + + @Environment(\.router) private var router var body: some View { + if hasDetails { + Button { + navigateToDetails() + } label: { + content + } + .buttonStyle(PlainButtonStyle()) + } else { + content + } + } + + var content: some View { HStack(spacing: 0) { // Content-specific view switch currentItem { @@ -40,7 +56,7 @@ struct TopListItemView: View { previousValue: previousItem?.metrics[metric], metric: metric, showDetails: showDetails, - showChevron: currentItem is TopListData.Post + showChevron: hasDetails ) } .padding(.vertical, 7) @@ -54,3 +70,25 @@ struct TopListItemView: View { ) } } + +// MARK: - Private Methods + +private extension TopListItemView { + var hasDetails: Bool { + switch currentItem { + case is TopListData.Post: + return true + default: + return false + } + } + + func navigateToDetails() { + switch currentItem { + case let post as TopListData.Post: + router.navigate(to: .postDetails(post: post, dateRange: dateRange)) + default: + break + } + } +} diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift index 6bc8cb0eb38e..2986164daf75 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift @@ -3,6 +3,7 @@ import SwiftUI struct TopListItemsView: View { let data: TopListChartData let itemLimit: Int + let dateRange: StatsDateRange var showDetails = true var body: some View { @@ -13,7 +14,8 @@ struct TopListItemsView: View { previousItem: item.previous, metric: data.metric, maxValue: data.maxValue, - showDetails: showDetails + showDetails: showDetails, + dateRange: dateRange ) .transition(.move(edge: .leading) .combined(with: .scale(scale: 0.75)) diff --git a/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift b/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift index 42b560583f92..6a0ef970dedb 100644 --- a/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift +++ b/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift @@ -64,8 +64,12 @@ class StatsHostingViewController: UIViewController { context = StatsContext(timeZone: siteTimezone, siteID: siteID, api: api) } + // Create the router with reference to navigation controller + let router = StatsRouter(navigationController: navigationController) + // Create the SwiftUI view let statsView = StatsMainView(context: context) + .environment(\.statsRouter, router) let hostingController = UIHostingController(rootView: AnyView(statsView)) // Add as child view controller From 7961e5ef349c508baa173fd3a64260f89b5f3f26 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Thu, 24 Jul 2025 14:43:43 -0400 Subject: [PATCH 049/349] Remove PeakPerformanceCard --- .../Screens/PostStatsDetailsView.swift | 58 +------------------ 1 file changed, 1 insertion(+), 57 deletions(-) diff --git a/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift b/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift index 73c87871839e..a2c70e8ec046 100644 --- a/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift +++ b/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift @@ -264,7 +264,7 @@ private struct PostStatsMetricsStripView: View { private struct PostLikesStripView: View { let likes: PostLikesData - + private let avatarSize: CGFloat = 28 private let maxVisibleAvatars = 6 @@ -349,62 +349,6 @@ private struct PostLikesStripView: View { } } -private struct PeakPerformanceCard: View { - let details: StatsPostDetails - - var body: some View { - VStack(alignment: .leading, spacing: Constants.step2) { - StatsCardTitleView(title: Strings.PostDetails.peakPerformance) - - VStack(spacing: Constants.step1) { - if let highestMonth = details.highestMonth { - HStack { - Text(Strings.PostDetails.bestMonth) - .font(.subheadline) - .foregroundColor(.secondary) - - Spacer() - - Text(StatsValueFormatter.formatNumber(highestMonth)) - .font(.subheadline.weight(.semibold)) - .monospacedDigit() - } - } - - if let highestDayAverage = details.highestDayAverage { - HStack { - Text(Strings.PostDetails.bestDayAverage) - .font(.subheadline) - .foregroundColor(.secondary) - - Spacer() - - Text(StatsValueFormatter.formatNumber(highestDayAverage)) - .font(.subheadline.weight(.semibold)) - .monospacedDigit() - } - } - - if let highestWeekAverage = details.highestWeekAverage { - HStack { - Text(Strings.PostDetails.bestWeekAverage) - .font(.subheadline) - .foregroundColor(.secondary) - - Spacer() - - Text(StatsValueFormatter.formatNumber(highestWeekAverage)) - .font(.subheadline.weight(.semibold)) - .monospacedDigit() - } - } - } - } - .padding(Constants.step2) - .frame(maxWidth: .infinity, alignment: .leading) - } -} - #Preview { NavigationStack { PostStatsDetailsView( From 50f92095e32b2b9015695dee4d3d5c0ab15fa164 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Thu, 24 Jul 2025 14:46:28 -0400 Subject: [PATCH 050/349] Update titles for months and years --- Modules/Sources/JetpackStats/Strings.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/JetpackStats/Strings.swift b/Modules/Sources/JetpackStats/Strings.swift index cdaad0fde1d2..348e4f86a1fe 100644 --- a/Modules/Sources/JetpackStats/Strings.swift +++ b/Modules/Sources/JetpackStats/Strings.swift @@ -138,7 +138,7 @@ enum Strings { static let more = AppLocalizedString("jetpackStats.postDetails.more", value: "More", comment: "Legend label for higher activity") // Monthly Activity - static let monthlyActivity = AppLocalizedString("jetpackStats.postDetails.monthlyActivity", value: "Monthly Activity", comment: "Title for monthly activity heatmap") + static let monthlyActivity = AppLocalizedString("jetpackStats.postDetails.monthsAndYears", value: "Months and Years", comment: "Title for monthly activity heatmap") static let views = AppLocalizedString("jetpackStats.postDetails.views", value: "views", comment: "Views label (lowercase)") static let total = AppLocalizedString("jetpackStats.postDetails.total", value: "Total", comment: "Total label") static let avgPerDay = AppLocalizedString("jetpackStats.postDetails.avgPerDay", value: "avg/day", comment: "Average per day abbreviation") From 894b281375677f770fe76d1454deb36f524414b1 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Thu, 24 Jul 2025 14:50:53 -0400 Subject: [PATCH 051/349] lintfix --- .../Cards/StandaloneChartCard.swift | 26 +++--- Modules/Sources/JetpackStats/Constants.swift | 2 +- .../Screens/PostStatsDetailsView.swift | 10 +-- .../JetpackStats/Screens/StatsMainView.swift | 8 +- .../Services/Data/PostLikeUser.swift | 8 +- .../Services/Mocks/MockStatsService.swift | 17 ++-- .../Services/Mocks/StatsDataAggregator.swift | 8 +- .../JetpackStats/Services/StatsService.swift | 6 +- .../Sources/JetpackStats/StatsRouter.swift | 8 +- Modules/Sources/JetpackStats/Strings.swift | 26 +++--- .../JetpackStats/Views/HeatmapView.swift | 16 ++-- .../Views/TopList/TopListItemView.swift | 2 +- .../JetpackStats/Views/WeeklyTrendsView.swift | 72 ++++++++-------- .../JetpackStats/Views/YearlyTrendsView.swift | 60 +++++++------- .../StatsDataAggregationTests.swift | 76 ++++++++--------- .../YearlyTrendsViewModelTests.swift | 82 +++++++++---------- .../Stats/StatsHostingViewController.swift | 2 +- 17 files changed, 213 insertions(+), 216 deletions(-) diff --git a/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift b/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift index 062f7263124c..6744e78a80fb 100644 --- a/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift +++ b/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift @@ -11,7 +11,7 @@ import Charts struct StandaloneChartCard: View { /// The data points to display in the chart let dataPoints: [DataPoint] - + /// The metric type being displayed (e.g., views, likes, comments) let metric: SiteMetric @@ -21,9 +21,9 @@ struct StandaloneChartCard: View { @State private var selectedChartType: ChartType = .line @State private var isShowingDatePicker = false @State private var chartData: ChartData? - + @ScaledMetric private var chartHeight = 160 - + @Environment(\.context) private var context @Environment(\.redactionReasons) private var redactionReasons @@ -61,11 +61,11 @@ struct StandaloneChartCard: View { currentPeriod: dateRange.dateInterval, previousPeriod: dateRange.effectiveComparisonInterval ) - + ChartValuesSummaryView(trend: trend, style: .compact) .padding(.top, 8) } - + chartView // Date range controls @@ -122,7 +122,7 @@ struct StandaloneChartCard: View { // MARK: – private var trend: TrendViewModel { - guard let chartData = chartData else { + guard let chartData else { return TrendViewModel(currentValue: 0, previousValue: 0, metric: metric) } return TrendViewModel( @@ -149,7 +149,7 @@ struct StandaloneChartCard: View { } // MARK: - Controls - + private var moreMenu: some View { Menu { Section { @@ -171,7 +171,7 @@ struct StandaloneChartCard: View { } .tint(Color.primary) } - + private var dateRangeControls: some View { HStack(spacing: Constants.step1) { // Date range menu button @@ -191,9 +191,9 @@ struct StandaloneChartCard: View { .clipShape(RoundedRectangle(cornerRadius: 8)) } .tint(Color.primary) - + Spacer() - + // Navigation controls HStack(spacing: 4) { navigationButton(direction: .backward) @@ -201,7 +201,7 @@ struct StandaloneChartCard: View { } } } - + @ViewBuilder private func navigationButton(direction: Calendar.NavigationDirection) -> some View { Button { @@ -280,7 +280,7 @@ private func generateChartData( #Preview { let calendar = Calendar.current let dateRange = calendar.makeDateRange(for: .last7Days) - + return StandaloneChartCard( dataPoints: generateMockDataPoints(days: 365), metric: .views, @@ -297,7 +297,7 @@ private func generateChartData( private func generateMockDataPoints(days: Int, valueRange: ClosedRange = 50...200) -> [DataPoint] { let calendar = Calendar.current let today = Date() - + return (0.. Color { if intensity == 0 { return Color(UIColor.secondarySystemBackground) diff --git a/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift b/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift index a2c70e8ec046..b68ffe40a206 100644 --- a/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift +++ b/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift @@ -4,7 +4,7 @@ import WordPressKit struct PostStatsDetailsView: View { let post: TopListData.Post - + @State private var details: StatsPostDetails? @State private var postLikes: PostLikesData? @State private var dataPoints: [DataPoint] = [] @@ -19,7 +19,7 @@ struct PostStatsDetailsView: View { self.post = post self.initialDateRange = dateRange } - + var body: some View { ScrollView { VStack(spacing: Constants.step2) { @@ -53,7 +53,7 @@ struct PostStatsDetailsView: View { if !details.recentWeeks.isEmpty { VStack(alignment: .leading, spacing: Constants.step2) { StatsCardTitleView(title: Strings.PostDetails.recentWeeks) - + WeeklyTrendsView( weeks: WeeklyTrendsView.Week.make(from: details.recentWeeks, using: context.calendar), calendar: context.calendar, @@ -68,7 +68,7 @@ struct PostStatsDetailsView: View { if !dataPoints.isEmpty { VStack(alignment: .leading, spacing: Constants.step2) { StatsCardTitleView(title: Strings.PostDetails.monthlyActivity) - + YearlyTrendsView( viewModel: YearlyTrendsViewModel( dataPoints: dataPoints, @@ -194,7 +194,7 @@ struct PostStatsDetailsView: View { } } } - + private func convertToDataPoints(from data: [StatsPostViews]) -> [DataPoint] { // Convert DateComponents to Date using site timezone (similar to how StatsService does it) var calendar = context.calendar diff --git a/Modules/Sources/JetpackStats/Screens/StatsMainView.swift b/Modules/Sources/JetpackStats/Screens/StatsMainView.swift index a5544e150337..89984fa8eece 100644 --- a/Modules/Sources/JetpackStats/Screens/StatsMainView.swift +++ b/Modules/Sources/JetpackStats/Screens/StatsMainView.swift @@ -52,15 +52,15 @@ public struct StatsMainView: View { private struct NavigationPreview: UIViewControllerRepresentable { let content: () -> Content - + init(@ViewBuilder content: @escaping () -> Content) { self.content = content } - + func makeUIViewController(context: Context) -> UINavigationController { let navigationController = UINavigationController() let router = StatsRouter(navigationController: navigationController) - + let hostingController = UIHostingController( rootView: content() .environment(\.router, router) @@ -69,7 +69,7 @@ private struct NavigationPreview: UIViewControllerRepresentable { navigationController.viewControllers = [hostingController] return navigationController } - + func updateUIViewController(_ uiViewController: UINavigationController, context: Context) { // No update needed } diff --git a/Modules/Sources/JetpackStats/Services/Data/PostLikeUser.swift b/Modules/Sources/JetpackStats/Services/Data/PostLikeUser.swift index 1bec17037824..b2e6935a1429 100644 --- a/Modules/Sources/JetpackStats/Services/Data/PostLikeUser.swift +++ b/Modules/Sources/JetpackStats/Services/Data/PostLikeUser.swift @@ -3,24 +3,24 @@ import Foundation struct PostLikesData: Equatable, Sendable { let users: [PostLikeUser] let totalCount: Int - + init(users: [PostLikeUser], totalCount: Int) { self.users = users self.totalCount = totalCount } - + struct PostLikeUser: Equatable, Identifiable, Sendable { let id: Int let name: String let avatarURL: URL? - + init(id: Int, name: String, avatarURL: URL? = nil) { self.id = id self.name = name self.avatarURL = avatarURL } } - + static let mock = PostLikesData(users: [ PostLikeUser(id: 0, name: "Alex Chen"), PostLikeUser(id: 1, name: "Maya Rodriguez"), diff --git a/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift b/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift index e389c81af33b..57b28cbe72a9 100644 --- a/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift +++ b/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift @@ -43,7 +43,7 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { var total = SiteMetricsSet() var output: [SiteMetric: [DataPoint]] = [:] - + let aggregator = StatsDataAggregator(calendar: calendar) for (metric, allDataPoints) in hourlyData { @@ -51,7 +51,7 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { let filteredDataPoints = allDataPoints.filter { interval.start <= $0.date && $0.date < interval.end } - + // Use processPeriod to aggregate and normalize the data let periodData = aggregator.processPeriod( dataPoints: filteredDataPoints, @@ -254,20 +254,20 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { guard let url = Bundle.module.url(forResource: "post-details", withExtension: "json") else { throw URLError(.fileDoesNotExist) } - + let data = try Data(contentsOf: url) let jsonObject = try JSONSerialization.jsonObject(with: data) as! [String: AnyObject] - + // Simulate network delay try? await Task.sleep(for: .milliseconds(Int.random(in: 200...500))) - + guard let details = StatsPostDetails(jsonDictionary: jsonObject) else { throw URLError(.cannotParseResponse) } - + return details } - + func getPostLikes(for postID: Int, count: Int) async throws -> PostLikesData { // Simulate network delay try? await Task.sleep(for: .milliseconds(Int.random(in: 200...500))) @@ -291,7 +291,7 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { let requestedCount = min(count, mockUsers.count) let selectedUsers = Array(mockUsers.prefix(requestedCount)) - + return PostLikesData(users: selectedUsers, totalCount: 26) } @@ -368,7 +368,6 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { } } - // MARK: - Data Generation /// Mutates item metrics based on growth factors and variations diff --git a/Modules/Sources/JetpackStats/Services/Mocks/StatsDataAggregator.swift b/Modules/Sources/JetpackStats/Services/Mocks/StatsDataAggregator.swift index fad6f4adef75..98baf862c241 100644 --- a/Modules/Sources/JetpackStats/Services/Mocks/StatsDataAggregator.swift +++ b/Modules/Sources/JetpackStats/Services/Mocks/StatsDataAggregator.swift @@ -87,7 +87,7 @@ struct StatsDataAggregator { } return dates } - + /// Processes a period of data by aggregating and normalizing data points. /// - Parameters: /// - dataPoints: Data points already filtered for the period @@ -103,7 +103,7 @@ struct StatsDataAggregator { ) -> PeriodData { // Aggregate and normalize data in one pass let normalizedData = aggregate(dataPoints, granularity: granularity, metric: metric) - + // Generate complete date sequence for the range let dateSequence = generateDateSequence(dateInterval: dateInterval, by: granularity.component) @@ -112,10 +112,10 @@ struct StatsDataAggregator { let aggregationDate = makeAggegationDate(for: date, granularity: granularity) return DataPoint(date: date, value: normalizedData[aggregationDate ?? date] ?? 0) } - + // Calculate total using DataPoint's getTotalValue method let total = DataPoint.getTotalValue(for: periodDataPoints, metric: metric) ?? 0 - + return PeriodData(dataPoints: periodDataPoints, total: total) } } diff --git a/Modules/Sources/JetpackStats/Services/StatsService.swift b/Modules/Sources/JetpackStats/Services/StatsService.swift index cf281fc7f7f5..b1d0befaa997 100644 --- a/Modules/Sources/JetpackStats/Services/StatsService.swift +++ b/Modules/Sources/JetpackStats/Services/StatsService.swift @@ -190,14 +190,14 @@ actor StatsService: StatsServiceProtocol { func getPostDetails(for postID: Int) async throws -> StatsPostDetails { try await service.getDetails(forPostID: postID) } - + func getPostLikes(for postID: Int, count: Int) async throws -> PostLikesData { // Create PostServiceRemoteREST instance let postService = PostServiceRemoteREST( wordPressComRestApi: api, siteID: NSNumber(value: siteID) ) - + // Fetch likes using the REST API let result = try await withCheckedThrowingContinuation { continuation in postService.getLikesForPostID( @@ -221,7 +221,7 @@ actor StatsService: StatsServiceProtocol { } ) } - + return result } diff --git a/Modules/Sources/JetpackStats/StatsRouter.swift b/Modules/Sources/JetpackStats/StatsRouter.swift index c61bf2a1c045..e49cc2ffca67 100644 --- a/Modules/Sources/JetpackStats/StatsRouter.swift +++ b/Modules/Sources/JetpackStats/StatsRouter.swift @@ -3,7 +3,7 @@ import UIKit public struct StatsRouter: Sendable { public weak var navigationController: UINavigationController? - + public init(navigationController: UINavigationController? = nil) { self.navigationController = navigationController } @@ -11,15 +11,15 @@ public struct StatsRouter: Sendable { @MainActor func navigate(to destination: StatsDestination) { guard let navigationController else { return } - + let viewController: UIViewController - + switch destination { case .postDetails(let post, let dateRange): let view = PostStatsDetailsView(post: post, dateRange: dateRange) viewController = UIHostingController(rootView: view) } - + navigationController.pushViewController(viewController, animated: true) } } diff --git a/Modules/Sources/JetpackStats/Strings.swift b/Modules/Sources/JetpackStats/Strings.swift index 348e4f86a1fe..95fd315fc1c3 100644 --- a/Modules/Sources/JetpackStats/Strings.swift +++ b/Modules/Sources/JetpackStats/Strings.swift @@ -114,7 +114,7 @@ enum Strings { ) } } - + enum PostDetails { static let title = AppLocalizedString("jetpackStats.postDetails.title", value: "Post Stats", comment: "Navigation title") static let allTimeStats = AppLocalizedString("jetpackStats.postDetails.allTimeStats", value: "All-time stats", comment: "Header for all-time statistics section") @@ -124,53 +124,53 @@ enum Strings { date ) } - + // Peak Performance static let peakPerformance = AppLocalizedString("jetpackStats.postDetails.peakPerformance", value: "Peak Performance", comment: "Title for peak performance card") static let bestMonth = AppLocalizedString("jetpackStats.postDetails.bestMonth", value: "Best Month", comment: "Label for highest monthly views") static let bestDayAverage = AppLocalizedString("jetpackStats.postDetails.bestDayAverage", value: "Best Day Average", comment: "Label for highest daily average views") static let bestWeekAverage = AppLocalizedString("jetpackStats.postDetails.bestWeekAverage", value: "Best Week Average", comment: "Label for highest weekly average views") - + // Weekly Activity static let recentWeeks = AppLocalizedString("jetpackStats.postDetails.recentWeeks", value: "Recent Weeks", comment: "Title for recent weeks activity heatmap") static let weeklyActivity = AppLocalizedString("jetpackStats.postDetails.weeklyActivity", value: "Weekly Activity", comment: "Title for weekly activity heatmap") static let less = AppLocalizedString("jetpackStats.postDetails.less", value: "Less", comment: "Legend label for lower activity") static let more = AppLocalizedString("jetpackStats.postDetails.more", value: "More", comment: "Legend label for higher activity") - + // Monthly Activity static let monthlyActivity = AppLocalizedString("jetpackStats.postDetails.monthsAndYears", value: "Months and Years", comment: "Title for monthly activity heatmap") static let views = AppLocalizedString("jetpackStats.postDetails.views", value: "views", comment: "Views label (lowercase)") static let total = AppLocalizedString("jetpackStats.postDetails.total", value: "Total", comment: "Total label") static let avgPerDay = AppLocalizedString("jetpackStats.postDetails.avgPerDay", value: "avg/day", comment: "Average per day abbreviation") - + // Likes static let noLikesYet = AppLocalizedString("jetpackStats.postDetails.noLikesYet", value: "No likes yet", comment: "Label") static func likesCount(_ count: Int) -> String { - let format = count == 1 + let format = count == 1 ? AppLocalizedString("jetpackStats.postDetails.like", value: "%1$d like", comment: "Singular like count. %1$d is the number.") : AppLocalizedString("jetpackStats.postDetails.likes", value: "%1$d likes", comment: "Plural likes count. %1$d is the number.") return String.localizedStringWithFormat(format, count) } - + // Accessibility static func weeklyActivityAccessibility(weeksCount: Int, metric: String, total: String) -> String { String.localizedStringWithFormat( - AppLocalizedString("jetpackStats.postDetails.weeklyActivity.accessibility", - value: "Weekly activity heatmap showing %1$d weeks of %2$@ data. Total: %3$@", + AppLocalizedString("jetpackStats.postDetails.weeklyActivity.accessibility", + value: "Weekly activity heatmap showing %1$d weeks of %2$@ data. Total: %3$@", comment: "VoiceOver description for weekly activity heatmap. %1$d is number of weeks, %2$@ is metric name, %3$@ is total value"), weeksCount, metric, total ) } - + static func yearlyActivityAccessibility(yearsCount: Int, metric: String, total: String) -> String { String.localizedStringWithFormat( - AppLocalizedString("jetpackStats.postDetails.yearlyActivity.accessibility", - value: "Yearly activity for %1$d years, %2$@: %3$@", + AppLocalizedString("jetpackStats.postDetails.yearlyActivity.accessibility", + value: "Yearly activity for %1$d years, %2$@: %3$@", comment: "VoiceOver description for yearly activity heatmap. %1$d is number of years, %2$@ is metric name, %3$@ is total value"), yearsCount, metric, total ) } - + // Tooltip static let weekTotal = AppLocalizedString("jetpackStats.postDetails.weekTotal", value: "Week Total", comment: "Label for weekly total in tooltip") static let dailyAverage = AppLocalizedString("jetpackStats.postDetails.dailyAverage", value: "Daily Average", comment: "Label for daily average in tooltip") diff --git a/Modules/Sources/JetpackStats/Views/HeatmapView.swift b/Modules/Sources/JetpackStats/Views/HeatmapView.swift index 3782554dd6e7..a78b9e9fbe3f 100644 --- a/Modules/Sources/JetpackStats/Views/HeatmapView.swift +++ b/Modules/Sources/JetpackStats/Views/HeatmapView.swift @@ -38,7 +38,7 @@ struct HeatmapCellView: View { self.color = color self.intensity = intensity } - + var body: some View { RoundedRectangle(cornerRadius: 4) .fill(color) @@ -60,16 +60,16 @@ struct HeatmapCellView: View { struct HeatmapLegendView: View { let metric: SiteMetric let labelWidth: CGFloat? - + init(metric: SiteMetric, labelWidth: CGFloat? = nil) { self.metric = metric self.labelWidth = labelWidth } - + var body: some View { HStack(spacing: Constants.step2) { HStack(spacing: 8) { - if let labelWidth = labelWidth { + if let labelWidth { Text(Strings.PostDetails.less) .font(.caption2) .foregroundColor(.secondary) @@ -79,7 +79,7 @@ struct HeatmapLegendView: View { .font(.caption2) .foregroundColor(.secondary) } - + HStack(spacing: 3) { ForEach(0..<5) { level in RoundedRectangle(cornerRadius: 4) @@ -87,16 +87,16 @@ struct HeatmapLegendView: View { .frame(width: 16, height: 16) } } - + Text(Strings.PostDetails.more) .font(.caption2) .foregroundColor(.secondary) } - + Spacer() } } - + private func heatmapColor(for intensity: Double) -> Color { Constants.heatmapColor(baseColor: metric.primaryColor, intensity: intensity) } diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift index 39a6d3e70a7e..f086776173d4 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift @@ -82,7 +82,7 @@ private extension TopListItemView { return false } } - + func navigateToDetails() { switch currentItem { case let post as TopListData.Post: diff --git a/Modules/Sources/JetpackStats/Views/WeeklyTrendsView.swift b/Modules/Sources/JetpackStats/Views/WeeklyTrendsView.swift index a5d579332609..b08e4dc550d6 100644 --- a/Modules/Sources/JetpackStats/Views/WeeklyTrendsView.swift +++ b/Modules/Sources/JetpackStats/Views/WeeklyTrendsView.swift @@ -3,13 +3,13 @@ import WordPressKit struct WeeklyTrendsView: View { let viewModel: WeeklyTrendsViewModel - + private let cellSpacing: CGFloat = 4 private let weekLabelWidth: CGFloat = 40 - + @State private var selectedDay: Week.Day? @State private var selectedWeek: Week? - + init(weeks: [Week], calendar: Calendar, timeZone: TimeZone, metric: SiteMetric = .views) { self.viewModel = WeeklyTrendsViewModel( weeks: weeks, @@ -18,32 +18,32 @@ struct WeeklyTrendsView: View { metric: metric ) } - + struct Week { struct Day { let date: Date let value: Int } - + let startDate: Date let days: [Day] - + static func make(from breakdown: StatsWeeklyBreakdown, using calendar: Calendar) -> Week? { guard let startDate = calendar.date(from: breakdown.startDay) else { return nil } - + let days = breakdown.days.compactMap { day -> Day? in guard let date = calendar.date(from: day.date) else { return nil } return Day(date: date, value: day.viewsCount) } - + return Week(startDate: startDate, days: days) } - + static func make(from breakdowns: [StatsWeeklyBreakdown], using calendar: Calendar) -> [Week] { breakdowns.compactMap { make(from: $0, using: calendar) } } } - + var body: some View { VStack(alignment: .leading, spacing: cellSpacing) { header @@ -110,7 +110,6 @@ struct WeeklyTrendsView: View { HeatmapLegendView(metric: viewModel.metric, labelWidth: weekLabelWidth) } - private var accessibilityLabel: String { let weeksCount = min(viewModel.weeks.count, 4) let totalValue = viewModel.weeks.prefix(4).flatMap { $0.days }.reduce(0) { $0 + $1.value } @@ -174,7 +173,7 @@ final class WeeklyTrendsViewModel: ObservableObject { func heatmapColor(for intensity: Double) -> Color { Constants.heatmapColor(baseColor: metric.primaryColor, intensity: intensity) } - + func previousWeek(for week: WeeklyTrendsView.Week) -> WeeklyTrendsView.Week? { guard let weekIndex = weeks.firstIndex(where: { $0.startDate == week.startDate }), weekIndex < weeks.count - 1 else { @@ -192,18 +191,18 @@ private struct DayCell: View { let metric: SiteMetric let formatter: WeeklyTrendsViewModel let calendar: Calendar - + @State private var showingPopover = false - + private var value: Int { day.value } - + private var intensity: Double { guard maxValue > 0 else { return 0 } return min(1.0, Double(value) / Double(maxValue)) } - + var body: some View { HeatmapCellView( value: value, @@ -229,16 +228,15 @@ private struct DayCell: View { .accessibilityAddTraits(.isButton) } - private var accessibilityLabel: String { let dateFormatter = DateFormatter() dateFormatter.dateStyle = .medium dateFormatter.timeStyle = .none dateFormatter.calendar = calendar - + let dateString = dateFormatter.string(from: day.date) let valueString = formatter.formatValue(value) - + return "\(dateString), \(valueString) \(metric.localizedTitle)" } } @@ -250,19 +248,19 @@ private struct WeeklyTrendsTooltipView: View { let metric: SiteMetric let calendar: Calendar let formatter: WeeklyTrendsViewModel - + private var weekTotal: Int { week.days.reduce(0) { $0 + $1.value } } - + private var previousWeekTotal: Int { previousWeek?.days.reduce(0) { $0 + $1.value } ?? 0 } - + private var averagePerDay: Int { week.days.isEmpty ? 0 : weekTotal / week.days.count } - + private var trendViewModel: TrendViewModel { TrendViewModel( currentValue: weekTotal, @@ -271,14 +269,14 @@ private struct WeeklyTrendsTooltipView: View { context: .regular ) } - + var body: some View { VStack(alignment: .leading, spacing: 8) { // Date header Text(formattedDate) .font(.subheadline) .fontWeight(.semibold) - + // Day value HStack(spacing: 6) { Circle() @@ -303,7 +301,7 @@ private struct WeeklyTrendsTooltipView: View { .font(.caption) .fontWeight(.medium) } - + // Average per day HStack(spacing: 4) { Text(Strings.PostDetails.dailyAverage) @@ -313,7 +311,7 @@ private struct WeeklyTrendsTooltipView: View { .font(.caption) .fontWeight(.medium) } - + // Week-over-week change if previousWeek != nil && (weekTotal != previousWeekTotal) { HStack(spacing: 4) { @@ -330,7 +328,7 @@ private struct WeeklyTrendsTooltipView: View { } .padding() } - + private var formattedDate: String { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "MMM d, yyyy" @@ -345,31 +343,31 @@ extension WeeklyTrendsView.Week { static func mockWeeks(count: Int = 8) -> [WeeklyTrendsView.Week] { let calendar = Calendar.current let today = Date() - + return (0.. some View { let monthlyData = viewModel.getMonthlyData(for: year) @@ -75,31 +75,31 @@ struct YearlyTrendsView: View { @MainActor final class YearlyTrendsViewModel: ObservableObject { let metric: SiteMetric - + private let calendar: Calendar private let valueFormatter: StatsValueFormatter let sortedYears: [Int] let maxMonthlyViews: Int - + private var monthlyData: [Int: [DataPoint]] = [:] // year -> array of 12 DataPoints (Jan=0, Dec=11) - + init(dataPoints: [DataPoint], calendar: Calendar, metric: SiteMetric = .views) { self.metric = metric self.calendar = calendar - + self.valueFormatter = StatsValueFormatter(metric: metric) - + // Initialize aggregator with the calendar let aggregator = StatsDataAggregator(calendar: calendar) - + // Use StatsDataAggregator to aggregate data by month let normalizedData = aggregator.aggregate(dataPoints, granularity: .month, metric: metric) - + // Process normalized data into year -> array of 12 months structure var monthlyData: [Int: [DataPoint]] = [:] var maxMonthlyViews = 0 - + // First, collect all years that have data var yearsWithData = Set() for (date, _) in normalizedData { @@ -108,50 +108,50 @@ final class YearlyTrendsViewModel: ObservableObject { yearsWithData.insert(year) } } - + // Initialize arrays with empty DataPoints for each year for year in yearsWithData { var yearData: [DataPoint] = [] - + // Create DataPoint for each month for month in 1...12 { var dateComponents = DateComponents() dateComponents.year = year dateComponents.month = month dateComponents.day = 1 - + if let monthDate = calendar.date(from: dateComponents) { yearData.append(DataPoint(date: monthDate, value: 0)) } } - + monthlyData[year] = yearData } - + // Fill in actual values for (date, value) in normalizedData { let components = calendar.dateComponents([.year, .month], from: date) guard let year = components.year, let month = components.month, month >= 1 && month <= 12 else { continue } - + // Update the DataPoint with the actual value monthlyData[year]?[month - 1] = DataPoint(date: date, value: value) - + // Track max monthly value maxMonthlyViews = max(maxMonthlyViews, value) } - + self.monthlyData = monthlyData self.sortedYears = monthlyData.keys.sorted(by: >) self.maxMonthlyViews = max(maxMonthlyViews, 1) // Avoid division by zero } - + func getMonthlyData(for year: Int) -> [DataPoint] { guard let yearData = monthlyData[year] else { return [] } return yearData } - + func formatValue(_ value: Int) -> String { valueFormatter.format(value: value, context: .compact) } @@ -162,9 +162,9 @@ private struct MonthCell: View { let metric: SiteMetric let maxValue: Int let formatter: YearlyTrendsViewModel - + @State private var showingPopover = false - + var body: some View { HeatmapCellView( value: dataPoint.value, @@ -187,7 +187,7 @@ private struct MonthCell: View { .accessibilityLabel(accessibilityLabel) .accessibilityAddTraits(.isButton) } - + private var accessibilityLabel: String { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "MMMM yyyy" @@ -201,14 +201,14 @@ private struct MonthlyTrendsTooltipView: View { let value: Int let metric: SiteMetric let formatter: YearlyTrendsViewModel - + var body: some View { VStack(alignment: .leading, spacing: 8) { // Month header Text(formattedDate) .font(.subheadline) .fontWeight(.semibold) - + // Month value HStack(spacing: 6) { Circle() @@ -224,7 +224,7 @@ private struct MonthlyTrendsTooltipView: View { } .padding() } - + private var formattedDate: String { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "MMMM yyyy" @@ -254,15 +254,15 @@ private struct MonthlyTrendsTooltipView: View { private func mockDataPoints() -> [DataPoint] { var dataPoints: [DataPoint] = [] let calendar = Calendar.current - + for year in [2021, 2022, 2023, 2024] { for month in 1...12 { // Skip future months if year == 2024 && month > 7 { continue } - + // Generate daily data points for each month let daysInMonth = calendar.range(of: .day, in: .month, for: calendar.date(from: DateComponents(year: year, month: month))!)?.count ?? 30 - + for day in 1...daysInMonth { if let date = calendar.date(from: DateComponents(year: year, month: month, day: day)) { let baseViews = year == 2024 ? 500 : (year == 2023 ? 400 : 200) @@ -272,6 +272,6 @@ private func mockDataPoints() -> [DataPoint] { } } } - + return dataPoints } diff --git a/Modules/Tests/JetpackStatsTests/StatsDataAggregationTests.swift b/Modules/Tests/JetpackStatsTests/StatsDataAggregationTests.swift index c7bb509311c9..eb2c2c6ade4d 100644 --- a/Modules/Tests/JetpackStatsTests/StatsDataAggregationTests.swift +++ b/Modules/Tests/JetpackStatsTests/StatsDataAggregationTests.swift @@ -216,13 +216,13 @@ struct StatsDataAggregationTests { // Single value: 400 / 1 = 400 #expect(aggregated[day2] == 400) } - + // MARK: - Process Period Tests - + @Test func processPeriodDailyGranularity() { let aggregator = StatsDataAggregator(calendar: calendar) - + // Create test data spanning multiple days let allDataPoints = [ DataPoint(date: Date("2025-01-14T10:00:00Z"), value: 50), // Outside range @@ -233,46 +233,46 @@ struct StatsDataAggregationTests { DataPoint(date: Date("2025-01-17T10:00:00Z"), value: 250), DataPoint(date: Date("2025-01-18T10:00:00Z"), value: 75) // Outside range ] - + // Create date interval for Jan 15-17 (exclusive end) let dateInterval = DateInterval( start: Date("2025-01-15T00:00:00Z"), end: Date("2025-01-18T00:00:00Z") ) - + // Filter data points for the period let filteredDataPoints = allDataPoints.filter { dataPoint in dateInterval.contains(dataPoint.date) } - + let result = aggregator.processPeriod( dataPoints: filteredDataPoints, dateInterval: dateInterval, granularity: .day, metric: .views ) - + // Should have 3 days of data #expect(result.dataPoints.count == 3) - + // Check aggregated values #expect(result.dataPoints[0].date == Date("2025-01-15T00:00:00Z")) #expect(result.dataPoints[0].value == 450) // 100 + 200 + 150 - + #expect(result.dataPoints[1].date == Date("2025-01-16T00:00:00Z")) #expect(result.dataPoints[1].value == 300) - + #expect(result.dataPoints[2].date == Date("2025-01-17T00:00:00Z")) #expect(result.dataPoints[2].value == 250) - + // Check total #expect(result.total == 1000) // 450 + 300 + 250 } - + @Test func processPeriodHourlyGranularity() { let aggregator = StatsDataAggregator(calendar: calendar) - + // Create test data with multiple values per hour let dataPoints = [ DataPoint(date: Date("2025-01-15T14:15:00Z"), value: 100), @@ -281,40 +281,40 @@ struct StatsDataAggregationTests { DataPoint(date: Date("2025-01-15T15:10:00Z"), value: 300), DataPoint(date: Date("2025-01-15T16:20:00Z"), value: 250) ] - + // Create date interval for 3 hours let dateInterval = DateInterval( start: Date("2025-01-15T14:00:00Z"), end: Date("2025-01-15T17:00:00Z") ) - + // Filter data points for the period let filteredDataPoints = dataPoints.filter { dataPoint in dateInterval.contains(dataPoint.date) } - + let result = aggregator.processPeriod( dataPoints: filteredDataPoints, dateInterval: dateInterval, granularity: .hour, metric: .views ) - + // Should have 3 hours of data #expect(result.dataPoints.count == 3) - + // Check aggregated values #expect(result.dataPoints[0].value == 450) // 14:00 hour: 100 + 200 + 150 #expect(result.dataPoints[1].value == 300) // 15:00 hour #expect(result.dataPoints[2].value == 250) // 16:00 hour - + #expect(result.total == 1000) } - + @Test func processPeriodWithAveragedMetric() { let aggregator = StatsDataAggregator(calendar: calendar) - + // Create test data let dataPoints = [ DataPoint(date: Date("2025-01-15T08:00:00Z"), value: 300), @@ -322,17 +322,17 @@ struct StatsDataAggregationTests { DataPoint(date: Date("2025-01-15T20:00:00Z"), value: 900), DataPoint(date: Date("2025-01-16T10:00:00Z"), value: 400) ] - + let dateInterval = DateInterval( start: Date("2025-01-15T00:00:00Z"), end: Date("2025-01-17T00:00:00Z") ) - + // Filter data points for the period let filteredDataPoints = dataPoints.filter { dataPoint in dateInterval.contains(dataPoint.date) } - + // Use timeOnSite which requires averaging let result = aggregator.processPeriod( dataPoints: filteredDataPoints, @@ -340,53 +340,53 @@ struct StatsDataAggregationTests { granularity: .day, metric: .timeOnSite ) - + // Values should be averaged per day #expect(result.dataPoints[0].value == 600) // (300 + 600 + 900) / 3 #expect(result.dataPoints[1].value == 400) // 400 / 1 - + // Total for averaged metrics is the average of all period values #expect(result.total == 500) // (600 + 400) / 2 } - + @Test func processPeriodWithEmptyDateRange() { let aggregator = StatsDataAggregator(calendar: calendar) - + let dataPoints = [ DataPoint(date: Date("2025-01-15T10:00:00Z"), value: 100), DataPoint(date: Date("2025-01-16T10:00:00Z"), value: 200) ] - + // Date interval with no matching data let dateInterval = DateInterval( start: Date("2025-01-20T00:00:00Z"), end: Date("2025-01-22T00:00:00Z") ) - + // Filter data points for the period (should be empty) let filteredDataPoints = dataPoints.filter { dataPoint in dateInterval.contains(dataPoint.date) } - + let result = aggregator.processPeriod( dataPoints: filteredDataPoints, dateInterval: dateInterval, granularity: .day, metric: .views ) - + // Should still have dates but with zero values #expect(result.dataPoints.count == 2) #expect(result.dataPoints[0].value == 0) #expect(result.dataPoints[1].value == 0) #expect(result.total == 0) } - + @Test func processPeriodMonthlyGranularity() { let aggregator = StatsDataAggregator(calendar: calendar) - + let dataPoints = [ DataPoint(date: Date("2025-01-15T10:00:00Z"), value: 100), DataPoint(date: Date("2025-01-25T10:00:00Z"), value: 200), @@ -394,24 +394,24 @@ struct StatsDataAggregationTests { DataPoint(date: Date("2025-02-20T10:00:00Z"), value: 400), DataPoint(date: Date("2025-03-05T10:00:00Z"), value: 500) ] - + let dateInterval = DateInterval( start: Date("2025-01-01T00:00:00Z"), end: Date("2025-03-01T00:00:00Z") ) - + // Filter data points for the period let filteredDataPoints = dataPoints.filter { dataPoint in dateInterval.contains(dataPoint.date) } - + let result = aggregator.processPeriod( dataPoints: filteredDataPoints, dateInterval: dateInterval, granularity: .month, metric: .views ) - + // Should have 2 months (Jan and Feb) #expect(result.dataPoints.count == 2) #expect(result.dataPoints[0].value == 300) // Jan: 100 + 200 diff --git a/Modules/Tests/JetpackStatsTests/YearlyTrendsViewModelTests.swift b/Modules/Tests/JetpackStatsTests/YearlyTrendsViewModelTests.swift index 2614f9c8b88c..c1c33231e4f9 100644 --- a/Modules/Tests/JetpackStatsTests/YearlyTrendsViewModelTests.swift +++ b/Modules/Tests/JetpackStatsTests/YearlyTrendsViewModelTests.swift @@ -5,45 +5,45 @@ import Foundation @MainActor @Suite struct YearlyTrendsViewModelTests { let calendar = Calendar.mock(timeZone: TimeZone(secondsFromGMT: 0)!) - + @Test func initWithEmptyDataPoints() { let viewModel = YearlyTrendsViewModel( dataPoints: [], calendar: calendar ) - + #expect(viewModel.sortedYears.isEmpty) #expect(viewModel.maxMonthlyViews == 1) // Should default to 1 to avoid division by zero } - + @Test func initWithSingleMonthData() { let dataPoints = [ DataPoint(date: Date("2025-01-15T10:00:00Z"), value: 100), DataPoint(date: Date("2025-01-20T10:00:00Z"), value: 200) ] - + let viewModel = YearlyTrendsViewModel( dataPoints: dataPoints, calendar: calendar ) - + #expect(viewModel.sortedYears == [2025]) #expect(viewModel.maxMonthlyViews == 300) // Sum of January values - + let monthlyData = viewModel.getMonthlyData(for: 2025) #expect(monthlyData.count == 12) - + // January should have the sum of values #expect(monthlyData[0].value == 300) - + // Other months should have 0 for month in 1..<12 { #expect(monthlyData[month].value == 0) } } - + @Test func initWithMultipleMonthsData() { let dataPoints = [ @@ -55,21 +55,21 @@ struct YearlyTrendsViewModelTests { // December data DataPoint(date: Date("2025-12-25T10:00:00Z"), value: 500) ] - + let viewModel = YearlyTrendsViewModel( dataPoints: dataPoints, calendar: calendar ) - + #expect(viewModel.maxMonthlyViews == 500) // December has the highest value - + let monthlyData = viewModel.getMonthlyData(for: 2025) #expect(monthlyData[0].value == 300) // January #expect(monthlyData[1].value == 0) // February #expect(monthlyData[2].value == 400) // March #expect(monthlyData[11].value == 500) // December } - + @Test func initWithMultipleYearsData() { let dataPoints = [ @@ -82,44 +82,44 @@ struct YearlyTrendsViewModelTests { // 2023 data DataPoint(date: Date("2023-09-15T10:00:00Z"), value: 150) ] - + let viewModel = YearlyTrendsViewModel( dataPoints: dataPoints, calendar: calendar ) - + // Years should be sorted in descending order #expect(viewModel.sortedYears == [2025, 2024, 2023]) #expect(viewModel.maxMonthlyViews == 400) // March 2025 has the highest - + // Check 2025 data let data2025 = viewModel.getMonthlyData(for: 2025) #expect(data2025[0].value == 300) // January #expect(data2025[2].value == 400) // March - + // Check 2024 data let data2024 = viewModel.getMonthlyData(for: 2024) #expect(data2024[5].value == 100) // June #expect(data2024[11].value == 200) // December - + // Check 2023 data let data2023 = viewModel.getMonthlyData(for: 2023) #expect(data2023[8].value == 150) // September } - + @Test func monthlyDataDatesAreCorrect() { let dataPoints = [ DataPoint(date: Date("2025-01-15T10:00:00Z"), value: 100) ] - + let viewModel = YearlyTrendsViewModel( dataPoints: dataPoints, calendar: calendar ) - + let monthlyData = viewModel.getMonthlyData(for: 2025) - + // Check that each month has the correct date (1st of each month) for (index, dataPoint) in monthlyData.enumerated() { let components = calendar.dateComponents([.year, .month, .day], from: dataPoint.date) @@ -128,7 +128,7 @@ struct YearlyTrendsViewModelTests { #expect(components.day == 1) } } - + @Test func aggregationWithDifferentMetrics() { let dataPoints = [ @@ -137,98 +137,98 @@ struct YearlyTrendsViewModelTests { DataPoint(date: Date("2025-01-15T10:00:00Z"), value: 600), DataPoint(date: Date("2025-01-20T10:00:00Z"), value: 900) ] - + // Test with views (sum strategy) let viewsViewModel = YearlyTrendsViewModel( dataPoints: dataPoints, calendar: calendar, metric: .views ) - + let viewsData = viewsViewModel.getMonthlyData(for: 2025) #expect(viewsData[0].value == 1800) // Sum: 300 + 600 + 900 - + // Test with timeOnSite (average strategy) let timeViewModel = YearlyTrendsViewModel( dataPoints: dataPoints, calendar: calendar, metric: .timeOnSite ) - + let timeData = timeViewModel.getMonthlyData(for: 2025) #expect(timeData[0].value == 600) // Average: (300 + 600 + 900) / 3 } - + @Test func formatValue() { let dataPoints = [ DataPoint(date: Date("2025-01-15T10:00:00Z"), value: 1234) ] - + let viewModel = YearlyTrendsViewModel( dataPoints: dataPoints, calendar: calendar ) - + #expect(viewModel.formatValue(1234) == "1.2K") #expect(viewModel.formatValue(0) == "0") #expect(viewModel.formatValue(999) == "999") #expect(viewModel.formatValue(1000) == "1K") } - + @Test func getMonthlyDataForNonExistentYear() { let dataPoints = [ DataPoint(date: Date("2025-01-15T10:00:00Z"), value: 100) ] - + let viewModel = YearlyTrendsViewModel( dataPoints: dataPoints, calendar: calendar ) - + // This should return empty array after the assertionFailure let data = viewModel.getMonthlyData(for: 2024) #expect(data.isEmpty) } - + @Test func handlesLeapYearCorrectly() { // Test with February data in leap year let dataPoints = [ DataPoint(date: Date("2024-02-29T10:00:00Z"), value: 100) // 2024 is a leap year ] - + let viewModel = YearlyTrendsViewModel( dataPoints: dataPoints, calendar: calendar ) - + let monthlyData = viewModel.getMonthlyData(for: 2024) #expect(monthlyData[1].value == 100) // February - + // Verify the date is set to Feb 1st let febComponents = calendar.dateComponents([.year, .month, .day], from: monthlyData[1].date) #expect(febComponents.year == 2024) #expect(febComponents.month == 2) #expect(febComponents.day == 1) } - + @Test func handlesTimeZoneCorrectly() { // Create calendar with different timezone let pstCalendar = Calendar.mock(timeZone: TimeZone(identifier: "America/Los_Angeles")!) - + // This date is late evening PST, which is next day in UTC let dataPoints = [ DataPoint(date: Date("2025-01-31T23:00:00-08:00"), value: 100) // Jan 31, 11 PM PST = Feb 1 UTC ] - + let viewModel = YearlyTrendsViewModel( dataPoints: dataPoints, calendar: pstCalendar ) - + let monthlyData = viewModel.getMonthlyData(for: 2025) // In PST timezone, this should still be January #expect(monthlyData[0].value == 100) // January diff --git a/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift b/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift index 6a0ef970dedb..cee58a06a796 100644 --- a/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift +++ b/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift @@ -66,7 +66,7 @@ class StatsHostingViewController: UIViewController { // Create the router with reference to navigation controller let router = StatsRouter(navigationController: navigationController) - + // Create the SwiftUI view let statsView = StatsMainView(context: context) .environment(\.statsRouter, router) From 6bf399832d45a0457201e13ea70bca86294d17fe Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Thu, 24 Jul 2025 14:52:11 -0400 Subject: [PATCH 052/349] Fix build error --- .../JetpackStats/Screens/StatsMainView.swift | 26 +++++++------------ .../Stats/StatsHostingViewController.swift | 3 +-- 2 files changed, 10 insertions(+), 19 deletions(-) diff --git a/Modules/Sources/JetpackStats/Screens/StatsMainView.swift b/Modules/Sources/JetpackStats/Screens/StatsMainView.swift index 89984fa8eece..fd8a14700d9a 100644 --- a/Modules/Sources/JetpackStats/Screens/StatsMainView.swift +++ b/Modules/Sources/JetpackStats/Screens/StatsMainView.swift @@ -5,9 +5,11 @@ public struct StatsMainView: View { @State private var isTabBarBackgroundShown = true private let context: StatsContext + private let router: StatsRouter - public init(context: StatsContext) { + public init(context: StatsContext, router: StatsRouter) { self.context = context + self.router = router } public var body: some View { @@ -22,6 +24,7 @@ public struct StatsMainView: View { .navigationTitle(Strings.stats) .navigationBarTitleDisplayMode(.inline) .environment(\.context, context) + .environment(\.router, router) } @ViewBuilder @@ -44,28 +47,17 @@ public struct StatsMainView: View { } #Preview { - NavigationPreview { - StatsMainView(context: .demo) - } - .ignoresSafeArea() + PreviewStatsMainView() + .ignoresSafeArea() } -private struct NavigationPreview: UIViewControllerRepresentable { - let content: () -> Content - - init(@ViewBuilder content: @escaping () -> Content) { - self.content = content - } +private struct PreviewStatsMainView: UIViewControllerRepresentable { func makeUIViewController(context: Context) -> UINavigationController { let navigationController = UINavigationController() let router = StatsRouter(navigationController: navigationController) - - let hostingController = UIHostingController( - rootView: content() - .environment(\.router, router) - ) - + let view = StatsMainView(context: .demo, router: router) + let hostingController = UIHostingController(rootView: view) navigationController.viewControllers = [hostingController] return navigationController } diff --git a/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift b/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift index cee58a06a796..f52e921bc806 100644 --- a/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift +++ b/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift @@ -68,8 +68,7 @@ class StatsHostingViewController: UIViewController { let router = StatsRouter(navigationController: navigationController) // Create the SwiftUI view - let statsView = StatsMainView(context: context) - .environment(\.statsRouter, router) + let statsView = StatsMainView(context: context, router: router) let hostingController = UIHostingController(rootView: AnyView(statsView)) // Add as child view controller From 237c9fb052c5bbacb49fc3931760b9867391a9e7 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Thu, 24 Jul 2025 14:55:43 -0400 Subject: [PATCH 053/349] Remove unused Strings --- Modules/Sources/JetpackStats/Strings.swift | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/Modules/Sources/JetpackStats/Strings.swift b/Modules/Sources/JetpackStats/Strings.swift index 95fd315fc1c3..49184c025a23 100644 --- a/Modules/Sources/JetpackStats/Strings.swift +++ b/Modules/Sources/JetpackStats/Strings.swift @@ -117,7 +117,6 @@ enum Strings { enum PostDetails { static let title = AppLocalizedString("jetpackStats.postDetails.title", value: "Post Stats", comment: "Navigation title") - static let allTimeStats = AppLocalizedString("jetpackStats.postDetails.allTimeStats", value: "All-time stats", comment: "Header for all-time statistics section") static func published(_ date: String) -> String { String.localizedStringWithFormat( AppLocalizedString("jetpackStats.postDetails.published", value: "Published %1$@", comment: "Shows when the post was published. %1$@ is the formatted date."), @@ -125,12 +124,6 @@ enum Strings { ) } - // Peak Performance - static let peakPerformance = AppLocalizedString("jetpackStats.postDetails.peakPerformance", value: "Peak Performance", comment: "Title for peak performance card") - static let bestMonth = AppLocalizedString("jetpackStats.postDetails.bestMonth", value: "Best Month", comment: "Label for highest monthly views") - static let bestDayAverage = AppLocalizedString("jetpackStats.postDetails.bestDayAverage", value: "Best Day Average", comment: "Label for highest daily average views") - static let bestWeekAverage = AppLocalizedString("jetpackStats.postDetails.bestWeekAverage", value: "Best Week Average", comment: "Label for highest weekly average views") - // Weekly Activity static let recentWeeks = AppLocalizedString("jetpackStats.postDetails.recentWeeks", value: "Recent Weeks", comment: "Title for recent weeks activity heatmap") static let weeklyActivity = AppLocalizedString("jetpackStats.postDetails.weeklyActivity", value: "Weekly Activity", comment: "Title for weekly activity heatmap") @@ -139,9 +132,6 @@ enum Strings { // Monthly Activity static let monthlyActivity = AppLocalizedString("jetpackStats.postDetails.monthsAndYears", value: "Months and Years", comment: "Title for monthly activity heatmap") - static let views = AppLocalizedString("jetpackStats.postDetails.views", value: "views", comment: "Views label (lowercase)") - static let total = AppLocalizedString("jetpackStats.postDetails.total", value: "Total", comment: "Total label") - static let avgPerDay = AppLocalizedString("jetpackStats.postDetails.avgPerDay", value: "avg/day", comment: "Average per day abbreviation") // Likes static let noLikesYet = AppLocalizedString("jetpackStats.postDetails.noLikesYet", value: "No likes yet", comment: "Label") @@ -162,15 +152,6 @@ enum Strings { ) } - static func yearlyActivityAccessibility(yearsCount: Int, metric: String, total: String) -> String { - String.localizedStringWithFormat( - AppLocalizedString("jetpackStats.postDetails.yearlyActivity.accessibility", - value: "Yearly activity for %1$d years, %2$@: %3$@", - comment: "VoiceOver description for yearly activity heatmap. %1$d is number of years, %2$@ is metric name, %3$@ is total value"), - yearsCount, metric, total - ) - } - // Tooltip static let weekTotal = AppLocalizedString("jetpackStats.postDetails.weekTotal", value: "Week Total", comment: "Label for weekly total in tooltip") static let dailyAverage = AppLocalizedString("jetpackStats.postDetails.dailyAverage", value: "Daily Average", comment: "Label for daily average in tooltip") From 3f4503fbe975ef8e8c8a4a9a9159b971c7258ec0 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Thu, 24 Jul 2025 15:08:24 -0400 Subject: [PATCH 054/349] Fix PostDetailsView using the wrong service --- .../Screens/PostStatsDetailsView.swift | 6 +++--- .../JetpackStats/Services/StatsService.swift | 2 +- .../Sources/JetpackStats/StatsRouter.swift | 19 +++---------------- .../Views/TopList/TopListItemView.swift | 6 +++++- 4 files changed, 12 insertions(+), 21 deletions(-) diff --git a/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift b/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift index b68ffe40a206..51ba46e584d3 100644 --- a/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift +++ b/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift @@ -168,15 +168,15 @@ struct PostStatsDetailsView: View { } private func loadPostDetails() async { - guard let postId = post.postID, let postIdInt = Int(postId) else { + guard let postID = Int(post.postID ?? "") else { self.error = URLError(.unknown, userInfo: [NSLocalizedDescriptionKey: Strings.Errors.generic]) self.isLoading = false return } - async let detailsTask = context.service.getPostDetails(for: postIdInt) + async let detailsTask = context.service.getPostDetails(for: postID) async let likesTask: PostLikesData? = { - try? await context.service.getPostLikes(for: postIdInt, count: 20) + try? await context.service.getPostLikes(for: postID, count: 20) }() do { diff --git a/Modules/Sources/JetpackStats/Services/StatsService.swift b/Modules/Sources/JetpackStats/Services/StatsService.swift index b1d0befaa997..ab07c1744545 100644 --- a/Modules/Sources/JetpackStats/Services/StatsService.swift +++ b/Modules/Sources/JetpackStats/Services/StatsService.swift @@ -209,7 +209,7 @@ actor StatsService: StatsServiceProtocol { let likeUsers = users.map { remoteLike in PostLikesData.PostLikeUser( id: remoteLike.userID.intValue, - name: remoteLike.displayName ?? remoteLike.username ?? "Unknown", + name: remoteLike.displayName ?? remoteLike.username ?? "", avatarURL: remoteLike.avatarURL.flatMap(URL.init) ) } diff --git a/Modules/Sources/JetpackStats/StatsRouter.swift b/Modules/Sources/JetpackStats/StatsRouter.swift index e49cc2ffca67..5bb7659be62b 100644 --- a/Modules/Sources/JetpackStats/StatsRouter.swift +++ b/Modules/Sources/JetpackStats/StatsRouter.swift @@ -9,25 +9,12 @@ public struct StatsRouter: Sendable { } @MainActor - func navigate(to destination: StatsDestination) { - guard let navigationController else { return } - - let viewController: UIViewController - - switch destination { - case .postDetails(let post, let dateRange): - let view = PostStatsDetailsView(post: post, dateRange: dateRange) - viewController = UIHostingController(rootView: view) - } - - navigationController.pushViewController(viewController, animated: true) + func navigate(to view: Content) { + let viewController = UIHostingController(rootView: view) + navigationController?.pushViewController(viewController, animated: true) } } -enum StatsDestination { - case postDetails(post: TopListData.Post, dateRange: StatsDateRange) -} - // MARK: - Environment Key private struct StatsRouterKey: EnvironmentKey { diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift index f086776173d4..b9d90fab0e7e 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift @@ -9,6 +9,7 @@ struct TopListItemView: View { let dateRange: StatsDateRange @Environment(\.router) private var router + @Environment(\.context) private var context var body: some View { if hasDetails { @@ -86,7 +87,10 @@ private extension TopListItemView { func navigateToDetails() { switch currentItem { case let post as TopListData.Post: - router.navigate(to: .postDetails(post: post, dateRange: dateRange)) + let detailsView = PostStatsDetailsView(post: post, dateRange: dateRange) + .environment(\.context, context) + .environment(\.router, router) + router.navigate(to: detailsView) default: break } From 2ecce6364877d4afede791ffd4af16e552b6cd42 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Thu, 24 Jul 2025 15:26:35 -0400 Subject: [PATCH 055/349] Show LikesListController when tapping on likes --- .../Screens/PostStatsDetailsView.swift | 70 ++++++++---- .../Sources/JetpackStats/StatsContext.swift | 8 +- .../Sources/JetpackStats/StatsRouter.swift | 25 ++++- .../Likes/LikesListController.swift | 12 ++ .../Stats/StatsHostingViewController.swift | 17 ++- .../Stats/StatsLikesListViewController.swift | 105 ++++++++++++++++++ 6 files changed, 208 insertions(+), 29 deletions(-) create mode 100644 WordPress/Classes/ViewRelated/Stats/StatsLikesListViewController.swift diff --git a/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift b/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift index 51ba46e584d3..95a7d18a04f6 100644 --- a/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift +++ b/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift @@ -12,6 +12,7 @@ struct PostStatsDetailsView: View { @State private var error: Error? @Environment(\.context) private var context + @Environment(\.router) private var router private let initialDateRange: StatsDateRange @@ -97,7 +98,12 @@ struct PostStatsDetailsView: View { postDetailsView if let postLikes { - PostLikesStripView(likes: postLikes) + Button { + navigateToLikesList() + } label: { + PostLikesStripView(likes: postLikes) + .contentShape(Rectangle()) + } } else if isLoading { PostLikesStripView(likes: .mock) .redacted(reason: .placeholder) @@ -106,9 +112,9 @@ struct PostStatsDetailsView: View { Divider() if let metrics { - PostStatsMetricsStripView(metrics: metrics) + PostStatsMetricsStripView(metrics: metrics, onLikesTapped: navigateToLikesList) } else if isLoading { - PostStatsMetricsStripView(metrics: .mock) + PostStatsMetricsStripView(metrics: .mock, onLikesTapped: nil) .redacted(reason: .placeholder) } else if let error { SimpleErrorView(error: error) @@ -214,15 +220,30 @@ struct PostStatsDetailsView: View { range: initialDateRange ).currentData } + + private func navigateToLikesList() { + guard let postID = Int(post.postID ?? ""), + let totalLikes = postLikes?.totalCount else { + return + } + router.navigateToLikesList(siteID: context.siteID, postID: postID, totalLikes: totalLikes) + } } private struct PostStatsMetricsStripView: View { let metrics: SiteMetricsSet + let onLikesTapped: (() -> Void)? var body: some View { HStack(spacing: Constants.step2) { ForEach([SiteMetric.views, .likes, .comments]) { metric in MetricView(metric: metric, value: metrics[metric]) + .contentShape(Rectangle()) + .onTapGesture { + if metric == .likes { + onLikesTapped?() + } + } } } } @@ -233,7 +254,7 @@ private struct PostStatsMetricsStripView: View { var body: some View { VStack(alignment: .leading, spacing: 0) { - VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 2) { Image(systemName: metric.systemImage) .font(.caption2.weight(.medium)) .foregroundColor(.secondary) @@ -241,13 +262,23 @@ private struct PostStatsMetricsStripView: View { Text(metric.localizedTitle.uppercased()) .font(.caption.weight(.medium)) .foregroundColor(.secondary) + + if metric != .views { + Image(systemName: "chevron.forward") + .font(.caption2.weight(.bold)) + .scaleEffect(x: 0.7, y: 0.7) + .foregroundStyle(.secondary) + .padding(.leading, 2) + } } - Text(formattedValue) - .contentTransition(.numericText()) - .animation(.spring, value: value) - .font(Font.make(.recoleta, textStyle: .title, weight: .medium)) - .foregroundColor(.primary) + HStack { + Text(formattedValue) + .contentTransition(.numericText()) + .animation(.spring, value: value) + .font(Font.make(.recoleta, textStyle: .title, weight: .medium)) + .foregroundColor(.primary) + } } .lineLimit(1) .frame(minWidth: 78, alignment: .leading) @@ -312,19 +343,14 @@ private struct PostLikesStripView: View { } private var viewMore: some View { - // Likes button - Button(action: { - // TODO: Navigate to likes detail screen - }) { - HStack(spacing: 4) { - Text(Strings.PostDetails.likesCount(likes.totalCount)) - .font(.subheadline) - .foregroundColor(.primary) - - Image(systemName: "chevron.right") - .font(.caption.weight(.semibold)) - .foregroundColor(.secondary.opacity(0.66)) - } + HStack(spacing: 4) { + Text(Strings.PostDetails.likesCount(likes.totalCount)) + .font(.subheadline) + .foregroundColor(.primary) + + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundColor(.secondary.opacity(0.66)) } } diff --git a/Modules/Sources/JetpackStats/StatsContext.swift b/Modules/Sources/JetpackStats/StatsContext.swift index 2bc1197c8958..65804ea36e8c 100644 --- a/Modules/Sources/JetpackStats/StatsContext.swift +++ b/Modules/Sources/JetpackStats/StatsContext.swift @@ -8,12 +8,14 @@ public struct StatsContext: Sendable { let calendar: Calendar let service: any StatsServiceProtocol let formatters: StatsFormatters + let siteID: Int public init(timeZone: TimeZone, siteID: Int, api: WordPressComRestApi) { - self.init(timeZone: timeZone, service: StatsService(siteID: siteID, api: api, timeZone: timeZone)) + self.init(timeZone: timeZone, siteID: siteID, service: StatsService(siteID: siteID, api: api, timeZone: timeZone)) } - init(timeZone: TimeZone, service: (any StatsServiceProtocol)) { + init(timeZone: TimeZone, siteID: Int, service: (any StatsServiceProtocol)) { + self.siteID = siteID self.timeZone = timeZone self.calendar = { var calendar = Calendar.current @@ -25,7 +27,7 @@ public struct StatsContext: Sendable { self.formatters = StatsFormatters(timeZone: timeZone) } - public static let demo = StatsContext(timeZone: .current, service: MockStatsService()) + public static let demo = StatsContext(timeZone: .current, siteID: 1, service: MockStatsService()) /// Memoized formatted pre-configured to work with the reporting time zone. final class StatsFormatters: Sendable { diff --git a/Modules/Sources/JetpackStats/StatsRouter.swift b/Modules/Sources/JetpackStats/StatsRouter.swift index 5bb7659be62b..5001e8687827 100644 --- a/Modules/Sources/JetpackStats/StatsRouter.swift +++ b/Modules/Sources/JetpackStats/StatsRouter.swift @@ -1,18 +1,37 @@ import SwiftUI import UIKit -public struct StatsRouter: Sendable { +public protocol StatsRouterDelegate: AnyObject { + func makeLikesListViewController(siteID: Int, postID: Int, totalLikes: Int) -> UIViewController? +} + +public final class StatsRouter: @unchecked Sendable { public weak var navigationController: UINavigationController? + private weak var _delegate: StatsRouterDelegate? + + public var delegate: StatsRouterDelegate? { + get { _delegate } + set { _delegate = newValue } + } - public init(navigationController: UINavigationController? = nil) { + public init(navigationController: UINavigationController? = nil, delegate: StatsRouterDelegate? = nil) { self.navigationController = navigationController + self._delegate = delegate } @MainActor - func navigate(to view: Content) { + public func navigate(to view: Content) { let viewController = UIHostingController(rootView: view) navigationController?.pushViewController(viewController, animated: true) } + + @MainActor + public func navigateToLikesList(siteID: Int, postID: Int, totalLikes: Int) { + guard let viewController = delegate?.makeLikesListViewController(siteID: siteID, postID: postID, totalLikes: totalLikes) else { + return + } + navigationController?.pushViewController(viewController, animated: true) + } } // MARK: - Environment Key diff --git a/WordPress/Classes/ViewRelated/Likes/LikesListController.swift b/WordPress/Classes/ViewRelated/Likes/LikesListController.swift index e47a74023d4e..e9546e606ec7 100644 --- a/WordPress/Classes/ViewRelated/Likes/LikesListController.swift +++ b/WordPress/Classes/ViewRelated/Likes/LikesListController.swift @@ -143,6 +143,18 @@ class LikesListController: NSObject { configureLoadingIndicator() } + /// Init with siteID and postID + /// + init(tableView: UITableView, siteID: NSNumber, postID: NSNumber, delegate: LikesListControllerDelegate? = nil) { + content = .post(id: postID) + self.siteID = siteID + self.tableView = tableView + self.delegate = delegate + + super.init() + configureLoadingIndicator() + } + private func configureLoadingIndicator() { loadingIndicator = UIActivityIndicatorView(style: .medium) loadingIndicator.frame = CGRect(x: 0, y: 0, width: tableView.bounds.width, height: 44) diff --git a/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift b/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift index f52e921bc806..ff6144be2756 100644 --- a/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift +++ b/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift @@ -65,7 +65,7 @@ class StatsHostingViewController: UIViewController { } // Create the router with reference to navigation controller - let router = StatsRouter(navigationController: navigationController) + let router = StatsRouter(navigationController: navigationController, delegate: self) // Create the SwiftUI view let statsView = StatsMainView(context: context, router: router) @@ -180,3 +180,18 @@ extension StatsHostingViewController { viewController.present(navController, animated: true) } } + +// MARK: - StatsRouterDelegate +extension StatsHostingViewController: StatsRouterDelegate { + func makeLikesListViewController(siteID: Int, postID: Int, totalLikes: Int) -> UIViewController? { + guard let siteID = blog.dotComID else { + return nil + } + + return StatsLikesListViewController( + siteID: siteID, + postID: NSNumber(value: postID), + totalLikes: totalLikes + ) + } +} diff --git a/WordPress/Classes/ViewRelated/Stats/StatsLikesListViewController.swift b/WordPress/Classes/ViewRelated/Stats/StatsLikesListViewController.swift new file mode 100644 index 000000000000..34571ab680fe --- /dev/null +++ b/WordPress/Classes/ViewRelated/Stats/StatsLikesListViewController.swift @@ -0,0 +1,105 @@ +import Foundation +import UIKit +import WordPressData +import WordPressUI + +/// A view controller that displays the list of users who liked a post from the Stats screen. +class StatsLikesListViewController: UITableViewController, NoResultsViewHost { + + // MARK: - Properties + private let siteID: NSNumber + private let postID: NSNumber + private var likesListController: LikesListController? + private var totalLikes: Int + + // MARK: - Init + init(siteID: NSNumber, postID: NSNumber, totalLikes: Int) { + self.siteID = siteID + self.postID = postID + self.totalLikes = totalLikes + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - View + override func viewDidLoad() { + super.viewDidLoad() + + configureViewTitle() + configureTable() + WPAnalytics.track(.likeListOpened, properties: ["list_type": "post", "source": "stats_post_details"]) + } + +} + +private extension StatsLikesListViewController { + + func configureViewTitle() { + let titleFormat = totalLikes == 1 ? TitleFormats.singular : TitleFormats.plural + navigationItem.title = String(format: titleFormat, totalLikes) + } + + func configureTable() { + tableView.register(LikeUserTableViewCell.defaultNib, + forCellReuseIdentifier: LikeUserTableViewCell.defaultReuseID) + + likesListController = LikesListController( + tableView: tableView, + siteID: siteID, + postID: postID, + delegate: self + ) + tableView.delegate = likesListController + tableView.dataSource = likesListController + + // The separator is controlled by LikeUserTableViewCell + tableView.separatorStyle = .none + + // Call refresh to ensure that the controller fetches the data. + likesListController?.refresh() + } + + func displayUserProfile(_ user: LikeUser, from indexPath: IndexPath) { + let userProfileVC = UserProfileSheetViewController(user: user) + userProfileVC.blogUrlPreviewedSource = "stats_post_likes_list_user_profile" + userProfileVC.modalPresentationStyle = .popover + userProfileVC.popoverPresentationController?.sourceView = tableView.cellForRow(at: indexPath) ?? view + userProfileVC.popoverPresentationController?.adaptiveSheetPresentationController.prefersGrabberVisible = true + userProfileVC.popoverPresentationController?.adaptiveSheetPresentationController.detents = [.medium()] + present(userProfileVC, animated: true) + + WPAnalytics.track(.userProfileSheetShown, properties: ["source": "stats_post_likes_list"]) + } + + struct TitleFormats { + static let singular = NSLocalizedString("%1$d Like", + comment: "Singular format string for view title displaying the number of post likes. %1$d is the number of likes.") + static let plural = NSLocalizedString("%1$d Likes", + comment: "Plural format string for view title displaying the number of post likes. %1$d is the number of likes.") + } + +} + +// MARK: - LikesListController Delegate +// +extension StatsLikesListViewController: LikesListControllerDelegate { + + func didSelectUser(_ user: LikeUser, at indexPath: IndexPath) { + displayUserProfile(user, from: indexPath) + } + + func showErrorView(title: String, subtitle: String?) { + configureAndDisplayNoResults(on: tableView, + title: title, + subtitle: subtitle, + image: "wp-illustration-reader-empty") + } + + func updatedTotalLikes(_ totalLikes: Int) { + self.totalLikes = totalLikes + configureViewTitle() + } +} From fabef5a6ae74f9dcdfa05ecf64937d347a73e52b Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Thu, 24 Jul 2025 15:32:06 -0400 Subject: [PATCH 056/349] Integrate commenets vc --- .../Screens/PostStatsDetailsView.swift | 27 +++++++++++++++---- .../Sources/JetpackStats/StatsRouter.swift | 9 +++++++ .../Stats/StatsHostingViewController.swift | 13 +++++++++ 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift b/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift index 95a7d18a04f6..9afcca40ddb4 100644 --- a/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift +++ b/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift @@ -112,9 +112,13 @@ struct PostStatsDetailsView: View { Divider() if let metrics { - PostStatsMetricsStripView(metrics: metrics, onLikesTapped: navigateToLikesList) + PostStatsMetricsStripView( + metrics: metrics, + onLikesTapped: navigateToLikesList, + onCommentsTapped: navigateToCommentsList + ) } else if isLoading { - PostStatsMetricsStripView(metrics: .mock, onLikesTapped: nil) + PostStatsMetricsStripView(metrics: .mock, onLikesTapped: nil, onCommentsTapped: nil) .redacted(reason: .placeholder) } else if let error { SimpleErrorView(error: error) @@ -228,11 +232,19 @@ struct PostStatsDetailsView: View { } router.navigateToLikesList(siteID: context.siteID, postID: postID, totalLikes: totalLikes) } + + private func navigateToCommentsList() { + guard let postID = Int(post.postID ?? "") else { + return + } + router.navigateToCommentsList(siteID: context.siteID, postID: postID) + } } private struct PostStatsMetricsStripView: View { let metrics: SiteMetricsSet let onLikesTapped: (() -> Void)? + let onCommentsTapped: (() -> Void)? var body: some View { HStack(spacing: Constants.step2) { @@ -240,8 +252,13 @@ private struct PostStatsMetricsStripView: View { MetricView(metric: metric, value: metrics[metric]) .contentShape(Rectangle()) .onTapGesture { - if metric == .likes { + switch metric { + case .likes: onLikesTapped?() + case .comments: + onCommentsTapped?() + default: + break } } } @@ -263,12 +280,12 @@ private struct PostStatsMetricsStripView: View { .font(.caption.weight(.medium)) .foregroundColor(.secondary) - if metric != .views { + if metric != .views && (value ?? 0) > 0 { Image(systemName: "chevron.forward") .font(.caption2.weight(.bold)) .scaleEffect(x: 0.7, y: 0.7) .foregroundStyle(.secondary) - .padding(.leading, 2) + .padding(.leading, 1) } } diff --git a/Modules/Sources/JetpackStats/StatsRouter.swift b/Modules/Sources/JetpackStats/StatsRouter.swift index 5001e8687827..6f8dcabd9678 100644 --- a/Modules/Sources/JetpackStats/StatsRouter.swift +++ b/Modules/Sources/JetpackStats/StatsRouter.swift @@ -3,6 +3,7 @@ import UIKit public protocol StatsRouterDelegate: AnyObject { func makeLikesListViewController(siteID: Int, postID: Int, totalLikes: Int) -> UIViewController? + func makeCommentsListViewController(siteID: Int, postID: Int) -> UIViewController? } public final class StatsRouter: @unchecked Sendable { @@ -32,6 +33,14 @@ public final class StatsRouter: @unchecked Sendable { } navigationController?.pushViewController(viewController, animated: true) } + + @MainActor + public func navigateToCommentsList(siteID: Int, postID: Int) { + guard let viewController = delegate?.makeCommentsListViewController(siteID: siteID, postID: postID) else { + return + } + navigationController?.pushViewController(viewController, animated: true) + } } // MARK: - Environment Key diff --git a/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift b/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift index ff6144be2756..5e021e6b4fbb 100644 --- a/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift +++ b/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift @@ -194,4 +194,17 @@ extension StatsHostingViewController: StatsRouterDelegate { totalLikes: totalLikes ) } + + func makeCommentsListViewController(siteID: Int, postID: Int) -> UIViewController? { + guard let siteID = blog.dotComID else { + return nil + } + + let commentsVC = ReaderCommentsViewController( + postID: NSNumber(value: postID), + siteID: siteID + ) + commentsVC.source = .postDetails + return commentsVC + } } From 1d6c47d49f3040cf8cdb3757ceb07eeaea1c363e Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Thu, 24 Jul 2025 16:04:18 -0400 Subject: [PATCH 057/349] Rework WeeklyHeatmap to display data based on dataPoints --- .../Screens/PostStatsDetailsView.swift | 47 +- .../JetpackStats/Screens/TrafficTabView.swift | 2 +- .../Services/Data/DataPoint.swift | 2 +- .../Services/Data/SiteMetric.swift | 2 +- .../Services/Mocks/StatsDataAggregator.swift | 2 +- .../JetpackStats/Views/WeeklyTrendsView.swift | 217 +++++----- .../WeeklyTrendsViewModelTests.swift | 404 ++++++++++++++++++ 7 files changed, 551 insertions(+), 125 deletions(-) create mode 100644 Modules/Tests/JetpackStatsTests/WeeklyTrendsViewModelTests.swift diff --git a/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift b/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift index 9afcca40ddb4..a6148ef85096 100644 --- a/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift +++ b/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift @@ -42,44 +42,41 @@ struct PostStatsDetailsView: View { .cardStyle() // Views Over Time Chart - if !dataPoints.isEmpty { + if details != nil { makeChartView(dataPoints: dataPoints) } else if isLoading { makeChartView(dataPoints: mockDataPoints) .redacted(reason: .placeholder) } - if let details { + if details != nil { // Weekly Trends Chart - if !details.recentWeeks.isEmpty { - VStack(alignment: .leading, spacing: Constants.step2) { - StatsCardTitleView(title: Strings.PostDetails.recentWeeks) - - WeeklyTrendsView( - weeks: WeeklyTrendsView.Week.make(from: details.recentWeeks, using: context.calendar), - calendar: context.calendar, - timeZone: context.timeZone + VStack(alignment: .leading, spacing: Constants.step2) { + StatsCardTitleView(title: Strings.PostDetails.recentWeeks) + + WeeklyTrendsView( + viewModel: WeeklyTrendsViewModel( + dataPoints: dataPoints, + calendar: context.calendar ) - } - .padding(Constants.step2) - .cardStyle() + ) } + .padding(Constants.step2) + .cardStyle() // Yearly Summary - if !dataPoints.isEmpty { - VStack(alignment: .leading, spacing: Constants.step2) { - StatsCardTitleView(title: Strings.PostDetails.monthlyActivity) - - YearlyTrendsView( - viewModel: YearlyTrendsViewModel( - dataPoints: dataPoints, - calendar: context.calendar - ) + VStack(alignment: .leading, spacing: Constants.step2) { + StatsCardTitleView(title: Strings.PostDetails.monthlyActivity) + + YearlyTrendsView( + viewModel: YearlyTrendsViewModel( + dataPoints: dataPoints, + calendar: context.calendar ) - } - .padding(Constants.step2) - .cardStyle() + ) } + .padding(Constants.step2) + .cardStyle() } } diff --git a/Modules/Sources/JetpackStats/Screens/TrafficTabView.swift b/Modules/Sources/JetpackStats/Screens/TrafficTabView.swift index b88b244b0def..7061a594c4e2 100644 --- a/Modules/Sources/JetpackStats/Screens/TrafficTabView.swift +++ b/Modules/Sources/JetpackStats/Screens/TrafficTabView.swift @@ -12,7 +12,7 @@ struct TrafficTabView: View { } var body: some View { - ScrollView(showsIndicators: false) { + ScrollView { VStack(spacing: Constants.step3) { ForEach(viewModels, id: \.id) { viewModel in makeItem(for: viewModel) diff --git a/Modules/Sources/JetpackStats/Services/Data/DataPoint.swift b/Modules/Sources/JetpackStats/Services/Data/DataPoint.swift index 18be43ab497f..c31961f2d36c 100644 --- a/Modules/Sources/JetpackStats/Services/Data/DataPoint.swift +++ b/Modules/Sources/JetpackStats/Services/Data/DataPoint.swift @@ -40,7 +40,7 @@ extension DataPoint { return nil } let total = dataPoints.reduce(0) { $0 + $1.value } - switch metric.aggregarionStrategy { + switch metric.aggregationStrategy { case .average: return total / dataPoints.count case .sum: diff --git a/Modules/Sources/JetpackStats/Services/Data/SiteMetric.swift b/Modules/Sources/JetpackStats/Services/Data/SiteMetric.swift index 1cfc260ea79b..39d34d09430c 100644 --- a/Modules/Sources/JetpackStats/Services/Data/SiteMetric.swift +++ b/Modules/Sources/JetpackStats/Services/Data/SiteMetric.swift @@ -66,7 +66,7 @@ extension SiteMetric { } } - var aggregarionStrategy: AggregationStrategy { + var aggregationStrategy: AggregationStrategy { switch self { case .views, .visitors, .likes, .comments, .posts, .downloads: return .sum diff --git a/Modules/Sources/JetpackStats/Services/Mocks/StatsDataAggregator.swift b/Modules/Sources/JetpackStats/Services/Mocks/StatsDataAggregator.swift index 98baf862c241..d2dedbc982d1 100644 --- a/Modules/Sources/JetpackStats/Services/Mocks/StatsDataAggregator.swift +++ b/Modules/Sources/JetpackStats/Services/Mocks/StatsDataAggregator.swift @@ -57,7 +57,7 @@ struct StatsDataAggregator { // Second pass: normalize based on metric strategy var normalizedData: [Date: Int] = [:] for (date, dataPoint) in aggregatedData { - switch metric.aggregarionStrategy { + switch metric.aggregationStrategy { case .sum: normalizedData[date] = dataPoint.sum case .average: diff --git a/Modules/Sources/JetpackStats/Views/WeeklyTrendsView.swift b/Modules/Sources/JetpackStats/Views/WeeklyTrendsView.swift index b08e4dc550d6..8e55742943ee 100644 --- a/Modules/Sources/JetpackStats/Views/WeeklyTrendsView.swift +++ b/Modules/Sources/JetpackStats/Views/WeeklyTrendsView.swift @@ -7,36 +7,27 @@ struct WeeklyTrendsView: View { private let cellSpacing: CGFloat = 4 private let weekLabelWidth: CGFloat = 40 - @State private var selectedDay: Week.Day? + @State private var selectedDay: DataPoint? @State private var selectedWeek: Week? - init(weeks: [Week], calendar: Calendar, timeZone: TimeZone, metric: SiteMetric = .views) { - self.viewModel = WeeklyTrendsViewModel( - weeks: weeks, - calendar: calendar, - timeZone: timeZone, - metric: metric - ) + init(viewModel: WeeklyTrendsViewModel) { + self.viewModel = viewModel } struct Week { - struct Day { - let date: Date - let value: Int - } - let startDate: Date - let days: [Day] + let days: [DataPoint] + let averagePerDay: Int static func make(from breakdown: StatsWeeklyBreakdown, using calendar: Calendar) -> Week? { guard let startDate = calendar.date(from: breakdown.startDay) else { return nil } - let days = breakdown.days.compactMap { day -> Day? in + let days = breakdown.days.compactMap { day -> DataPoint? in guard let date = calendar.date(from: day.date) else { return nil } - return Day(date: date, value: day.viewsCount) + return DataPoint(date: date, value: day.viewsCount) } - return Week(startDate: startDate, days: days) + return Week(startDate: startDate, days: days, averagePerDay: 0) } static func make(from breakdowns: [StatsWeeklyBreakdown], using calendar: Calendar) -> [Week] { @@ -123,27 +114,29 @@ struct WeeklyTrendsView: View { final class WeeklyTrendsViewModel: ObservableObject { let weeks: [WeeklyTrendsView.Week] let calendar: Calendar - let timeZone: TimeZone let metric: SiteMetric private let valueFormatter: StatsValueFormatter private let weekFormatter: DateFormatter + private let aggregator: StatsDataAggregator let dayLabels: [String] let maxValue: Int - init(weeks: [WeeklyTrendsView.Week], calendar: Calendar, timeZone: TimeZone, metric: SiteMetric = .views) { - self.weeks = weeks + init(dataPoints: [DataPoint], calendar: Calendar, metric: SiteMetric = .views) { self.calendar = calendar - self.timeZone = timeZone self.metric = metric + // Initialize aggregator + self.aggregator = StatsDataAggregator(calendar: calendar) + // Initialize formatters self.valueFormatter = StatsValueFormatter(metric: metric) self.weekFormatter = DateFormatter() self.weekFormatter.dateFormat = "MMM d" - self.weekFormatter.timeZone = timeZone + self.weekFormatter.calendar = calendar + self.weekFormatter.timeZone = calendar.timeZone // Cache day labels let formatter = DateFormatter() @@ -158,8 +151,44 @@ final class WeeklyTrendsViewModel: ObservableObject { let reorderedSymbols = Array(symbols[(firstWeekday - 1)...]) + Array(symbols[..<(firstWeekday - 1)]) self.dayLabels = reorderedSymbols + // Process data points into weeks + let allWeeks = Self.processDataIntoWeeks(dataPoints: dataPoints, calendar: calendar, metric: metric) + + // Keep only the most recent 5 weeks + self.weeks = Array(allWeeks.prefix(5)) + // Calculate max value once - self.maxValue = weeks.flatMap { $0.days }.map { $0.value }.max() ?? 1 + self.maxValue = self.weeks.flatMap { $0.days }.map { $0.value }.max() ?? 1 + } + + private static func processDataIntoWeeks(dataPoints: [DataPoint], calendar: Calendar, metric: SiteMetric) -> [WeeklyTrendsView.Week] { + guard !dataPoints.isEmpty else { return [] } + + // Group data points by week + var weeklyData: [Date: [DataPoint]] = [:] + + for dataPoint in dataPoints { + let startOfWeek = calendar.dateInterval(of: .weekOfYear, for: dataPoint.date)?.start ?? dataPoint.date + weeklyData[startOfWeek, default: []].append(dataPoint) + } + + // Create Week objects with sorted days and calculated average + let weeks = weeklyData.map { startDate, days in + let sortedDays = days.sorted { $0.date < $1.date } + let weekTotal = DataPoint.getTotalValue(for: sortedDays, metric: metric) ?? 0 + let averagePerDay: Int + if sortedDays.isEmpty { + averagePerDay = 0 + } else if metric.aggregationStrategy == .average { + averagePerDay = weekTotal + } else { + averagePerDay = weekTotal / sortedDays.count + } + return WeeklyTrendsView.Week(startDate: startDate, days: sortedDays, averagePerDay: averagePerDay) + } + + // Sort weeks by start date (most recent first) + return weeks.sorted { $0.startDate > $1.startDate } } func weekLabel(for week: WeeklyTrendsView.Week) -> String { @@ -184,7 +213,7 @@ final class WeeklyTrendsViewModel: ObservableObject { } private struct DayCell: View { - let day: WeeklyTrendsView.Week.Day + let day: DataPoint let week: WeeklyTrendsView.Week let previousWeek: WeeklyTrendsView.Week? let maxValue: Int @@ -242,27 +271,32 @@ private struct DayCell: View { } private struct WeeklyTrendsTooltipView: View { - let day: WeeklyTrendsView.Week.Day + let day: DataPoint let week: WeeklyTrendsView.Week let previousWeek: WeeklyTrendsView.Week? let metric: SiteMetric let calendar: Calendar let formatter: WeeklyTrendsViewModel - private var weekTotal: Int { - week.days.reduce(0) { $0 + $1.value } + private var weekTotal: Int? { + week.days.isEmpty ? nil : DataPoint.getTotalValue(for: week.days, metric: metric) } - private var previousWeekTotal: Int { - previousWeek?.days.reduce(0) { $0 + $1.value } ?? 0 + private var previousWeekTotal: Int? { + guard let previousWeek else { return nil } + return previousWeek.days.isEmpty ? nil : DataPoint.getTotalValue(for: previousWeek.days, metric: metric) } private var averagePerDay: Int { - week.days.isEmpty ? 0 : weekTotal / week.days.count + week.averagePerDay } - private var trendViewModel: TrendViewModel { - TrendViewModel( + private var trendViewModel: TrendViewModel? { + guard let weekTotal, + let previousWeekTotal else { + return nil + } + return TrendViewModel( currentValue: weekTotal, previousValue: previousWeekTotal, metric: metric, @@ -293,13 +327,15 @@ private struct WeeklyTrendsTooltipView: View { // Week stats VStack(alignment: .leading, spacing: 4) { // Week total - HStack(spacing: 4) { - Text(Strings.PostDetails.weekTotal) - .font(.caption) - .foregroundColor(.secondary) - Text(formatter.formatValue(weekTotal)) - .font(.caption) - .fontWeight(.medium) + if let weekTotal { + HStack(spacing: 4) { + Text(Strings.PostDetails.weekTotal) + .font(.caption) + .foregroundColor(.secondary) + Text(formatter.formatValue(weekTotal)) + .font(.caption) + .fontWeight(.medium) + } } // Average per day @@ -313,7 +349,10 @@ private struct WeeklyTrendsTooltipView: View { } // Week-over-week change - if previousWeek != nil && (weekTotal != previousWeekTotal) { + if let trendViewModel, + let weekTotal, + let previousWeekTotal, + weekTotal != previousWeekTotal { HStack(spacing: 4) { Text(Strings.PostDetails.weekOverWeek) .font(.caption) @@ -339,58 +378,41 @@ private struct WeeklyTrendsTooltipView: View { // MARK: - Mock Data -extension WeeklyTrendsView.Week { - static func mockWeeks(count: Int = 8) -> [WeeklyTrendsView.Week] { - let calendar = Calendar.current - let today = Date() - - return (0.. [DataPoint] { + let calendar = Calendar.current + let today = Date() + var dataPoints: [DataPoint] = [] - // Generate realistic view counts with patterns - let baseViews = Int.random(in: 20...150) + for weekOffset in 0.. [DataPoint] { + mockDataPoints(weeks: weeks).map { dataPoint in + DataPoint(date: dataPoint.date, value: Int.random(in: 150...250)) } +} - static var mockEmpty: [WeeklyTrendsView.Week] { - mockWeeks(count: 8).map { week in - WeeklyTrendsView.Week( - startDate: week.startDate, - days: week.days.map { day in - WeeklyTrendsView.Week.Day(date: day.date, value: 0) - } - ) - } +private func mockEmptyDataPoints(weeks: Int = 4) -> [DataPoint] { + mockDataPoints(weeks: weeks).map { dataPoint in + DataPoint(date: dataPoint.date, value: 0) } } @@ -400,28 +422,31 @@ extension WeeklyTrendsView.Week { ScrollView { VStack(spacing: Constants.step2) { WeeklyTrendsView( - weeks: WeeklyTrendsView.Week.mockWeeks(count: 4), - calendar: StatsContext.demo.calendar, - timeZone: StatsContext.demo.timeZone, - metric: .views + viewModel: WeeklyTrendsViewModel( + dataPoints: mockDataPoints(), + calendar: StatsContext.demo.calendar, + metric: .views + ) ) .padding(Constants.step2) .cardStyle() WeeklyTrendsView( - weeks: Array(WeeklyTrendsView.Week.mockHighTraffic.prefix(4)), - calendar: StatsContext.demo.calendar, - timeZone: StatsContext.demo.timeZone, - metric: .views + viewModel: WeeklyTrendsViewModel( + dataPoints: mockHighTrafficDataPoints(), + calendar: StatsContext.demo.calendar, + metric: .views + ) ) .padding(Constants.step2) .cardStyle() WeeklyTrendsView( - weeks: Array(WeeklyTrendsView.Week.mockEmpty.prefix(4)), - calendar: StatsContext.demo.calendar, - timeZone: StatsContext.demo.timeZone, - metric: .views + viewModel: WeeklyTrendsViewModel( + dataPoints: mockEmptyDataPoints(), + calendar: StatsContext.demo.calendar, + metric: .views + ) ) .padding(Constants.step2) .cardStyle() diff --git a/Modules/Tests/JetpackStatsTests/WeeklyTrendsViewModelTests.swift b/Modules/Tests/JetpackStatsTests/WeeklyTrendsViewModelTests.swift new file mode 100644 index 000000000000..11742f7a6a92 --- /dev/null +++ b/Modules/Tests/JetpackStatsTests/WeeklyTrendsViewModelTests.swift @@ -0,0 +1,404 @@ +import Testing +import Foundation +@testable import JetpackStats + +@Suite("WeeklyTrendsViewModel Tests") +@MainActor +struct WeeklyTrendsViewModelTests { + + private let calendar = Calendar.mock(timeZone: TimeZone(secondsFromGMT: 0)!) + + // MARK: - Initialization Tests + + @Test("Initializes with data points") + func initialization() { + // Given + let dataPoints = [ + DataPoint(date: Date("2025-01-01T00:00:00Z"), value: 100), + DataPoint(date: Date("2025-01-02T00:00:00Z"), value: 150), + DataPoint(date: Date("2025-01-08T00:00:00Z"), value: 200), + DataPoint(date: Date("2025-01-09T00:00:00Z"), value: 250) + ] + + // When + let viewModel = WeeklyTrendsViewModel( + dataPoints: dataPoints, + calendar: calendar, + metric: .views + ) + + // Then + #expect(viewModel.weeks.count == 2) + #expect(viewModel.metric == .views) + #expect(viewModel.calendar == calendar) + #expect(viewModel.maxValue > 0) + } + + @Test("Handles empty data points") + func emptyDataPoints() { + // Given + let dataPoints: [DataPoint] = [] + + // When + let viewModel = WeeklyTrendsViewModel( + dataPoints: dataPoints, + calendar: calendar, + metric: .views + ) + + // Then + #expect(viewModel.weeks.count == 0) + #expect(viewModel.maxValue == 1) + } + + // MARK: - Week Processing Tests + + @Test("Sorts weeks by most recent first") + func weeksAreSortedByMostRecent() { + // Given + let dataPoints = [ + // Week 1: Dec 29, 2024 - Jan 4, 2025 + DataPoint(date: Date("2024-12-29T00:00:00Z"), value: 100), + DataPoint(date: Date("2024-12-30T00:00:00Z"), value: 110), + DataPoint(date: Date("2025-01-01T00:00:00Z"), value: 120), + // Week 2: Jan 5-11, 2025 + DataPoint(date: Date("2025-01-05T00:00:00Z"), value: 130), + DataPoint(date: Date("2025-01-06T00:00:00Z"), value: 140), + DataPoint(date: Date("2025-01-07T00:00:00Z"), value: 150), + // Week 3: Jan 12-18, 2025 + DataPoint(date: Date("2025-01-12T00:00:00Z"), value: 160), + DataPoint(date: Date("2025-01-13T00:00:00Z"), value: 170), + DataPoint(date: Date("2025-01-14T00:00:00Z"), value: 180) + ] + + // When + let viewModel = WeeklyTrendsViewModel( + dataPoints: dataPoints, + calendar: calendar, + metric: .views + ) + + // Then + #expect(viewModel.weeks.count == 3) + for i in 0.. viewModel.weeks[i + 1].startDate) + } + } + + @Test("Limits to five most recent weeks") + func limitsToFiveMostRecentWeeks() { + // Given + var dataPoints: [DataPoint] = [] + let baseDate = Date("2025-01-15T00:00:00Z") + + // Create 8 weeks of data + for weekOffset in 0..<8 { + for dayOffset in 0..<7 { + let date = calendar.date(byAdding: .day, value: -(weekOffset * 7 + dayOffset), to: baseDate)! + dataPoints.append(DataPoint(date: date, value: 100 + weekOffset * 10 + dayOffset)) + } + } + + // When + let viewModel = WeeklyTrendsViewModel( + dataPoints: dataPoints, + calendar: calendar, + metric: .views + ) + + // Then + #expect(viewModel.weeks.count == 5) + // Verify they are the most recent weeks + for i in 0.. viewModel.weeks[i + 1].startDate) + } + } + + @Test("Sorts days within week") + func daysWithinWeekAreSorted() { + // Given - shuffled days within a single week + let dataPoints = [ + DataPoint(date: Date("2025-01-08T00:00:00Z"), value: 130), // Wed + DataPoint(date: Date("2025-01-06T00:00:00Z"), value: 110), // Mon + DataPoint(date: Date("2025-01-10T00:00:00Z"), value: 150), // Fri + DataPoint(date: Date("2025-01-05T00:00:00Z"), value: 100), // Sun + DataPoint(date: Date("2025-01-07T00:00:00Z"), value: 120), // Tue + DataPoint(date: Date("2025-01-09T00:00:00Z"), value: 140), // Thu + DataPoint(date: Date("2025-01-11T00:00:00Z"), value: 160) // Sat + ] + + // When + let viewModel = WeeklyTrendsViewModel( + dataPoints: dataPoints, + calendar: calendar, + metric: .views + ) + + // Then + #expect(viewModel.weeks.count == 1) + let week = viewModel.weeks[0] + #expect(week.days.count == 7) + for i in 0.. Date: Thu, 24 Jul 2025 16:24:28 -0400 Subject: [PATCH 058/349] Computer PostDetailsData in the background --- .../Screens/PostStatsDetailsView.swift | 128 ++++++++++-------- .../JetpackStats/Views/WeeklyTrendsView.swift | 1 - .../JetpackStats/Views/YearlyTrendsView.swift | 1 - 3 files changed, 70 insertions(+), 60 deletions(-) diff --git a/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift b/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift index a6148ef85096..686dabf9a50b 100644 --- a/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift +++ b/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift @@ -5,10 +5,10 @@ import WordPressKit struct PostStatsDetailsView: View { let post: TopListData.Post - @State private var details: StatsPostDetails? - @State private var postLikes: PostLikesData? - @State private var dataPoints: [DataPoint] = [] - @State private var isLoading = true + @State private var data: PostDetailsData? + @State private var likes: PostLikesData? + @State private var isLoadingDetails = true + @State private var isLoadingLikes = true @State private var error: Error? @Environment(\.context) private var context @@ -42,24 +42,18 @@ struct PostStatsDetailsView: View { .cardStyle() // Views Over Time Chart - if details != nil { - makeChartView(dataPoints: dataPoints) - } else if isLoading { + if let data { + makeChartView(dataPoints: data.dataPoints) + } else if isLoadingDetails { makeChartView(dataPoints: mockDataPoints) .redacted(reason: .placeholder) } - if details != nil { + if let data { // Weekly Trends Chart VStack(alignment: .leading, spacing: Constants.step2) { StatsCardTitleView(title: Strings.PostDetails.recentWeeks) - - WeeklyTrendsView( - viewModel: WeeklyTrendsViewModel( - dataPoints: dataPoints, - calendar: context.calendar - ) - ) + WeeklyTrendsView(viewModel: data.weeklyTrends) } .padding(Constants.step2) .cardStyle() @@ -67,13 +61,7 @@ struct PostStatsDetailsView: View { // Yearly Summary VStack(alignment: .leading, spacing: Constants.step2) { StatsCardTitleView(title: Strings.PostDetails.monthlyActivity) - - YearlyTrendsView( - viewModel: YearlyTrendsViewModel( - dataPoints: dataPoints, - calendar: context.calendar - ) - ) + YearlyTrendsView(viewModel: data.yearlyTrends) } .padding(Constants.step2) .cardStyle() @@ -94,14 +82,14 @@ struct PostStatsDetailsView: View { VStack(alignment: .leading, spacing: Constants.step2) { postDetailsView - if let postLikes { + if let likes { Button { navigateToLikesList() } label: { - PostLikesStripView(likes: postLikes) + PostLikesStripView(likes: likes) .contentShape(Rectangle()) } - } else if isLoading { + } else if isLoadingLikes { PostLikesStripView(likes: .mock) .redacted(reason: .placeholder) } @@ -114,7 +102,7 @@ struct PostStatsDetailsView: View { onLikesTapped: navigateToLikesList, onCommentsTapped: navigateToCommentsList ) - } else if isLoading { + } else if isLoadingDetails { PostStatsMetricsStripView(metrics: .mock, onLikesTapped: nil, onCommentsTapped: nil) .redacted(reason: .placeholder) } else if let error { @@ -134,14 +122,14 @@ struct PostStatsDetailsView: View { .multilineTextAlignment(.leading) .lineLimit(3) - if let dateGMT = post.date ?? details?.post?.dateGMT { + if let dateGMT = post.date ?? data?.post?.dateGMT { HStack(spacing: 6) { Text(Strings.PostDetails.published(formatPublishedDate(dateGMT))) .font(.subheadline) .foregroundColor(.secondary) // Permalink button - if let postURL = post.postURL ?? details?.post?.permalink.flatMap(URL.init) { + if let postURL = post.postURL ?? data?.post?.permalink.flatMap(URL.init) { Link(destination: postURL) { Image(systemName: "link") .font(.footnote) @@ -156,13 +144,13 @@ struct PostStatsDetailsView: View { // MARK: - Data private var metrics: SiteMetricsSet? { - guard let details else { + guard let data else { return nil } return SiteMetricsSet( - views: details.totalViewsCount, - likes: postLikes?.totalCount, - comments: details.post?.commentCount.flatMap { Int($0) } + views: data.views, + likes: likes?.totalCount, + comments: data.comments ) } @@ -177,43 +165,35 @@ struct PostStatsDetailsView: View { private func loadPostDetails() async { guard let postID = Int(post.postID ?? "") else { self.error = URLError(.unknown, userInfo: [NSLocalizedDescriptionKey: Strings.Errors.generic]) - self.isLoading = false + self.isLoadingDetails = false return } - async let detailsTask = context.service.getPostDetails(for: postID) - async let likesTask: PostLikesData? = { - try? await context.service.getPostLikes(for: postID, count: 20) - }() + // Load likes in parallel and ignore errors + Task { + do { + self.likes = try await context.service.getPostLikes(for: postID, count: 10) + } catch { + // Do nothing + } + self.isLoadingLikes = false + } do { - let (details, likes) = try await (detailsTask, likesTask) + let details = try await context.service.getPostDetails(for: postID) + let data = await makeData(with: details, calendar: context.calendar) withAnimation(.spring) { - self.details = details - self.postLikes = likes - self.dataPoints = convertToDataPoints(from: details.data) - self.isLoading = false + self.data = data + self.isLoadingDetails = false } } catch { withAnimation(.spring) { self.error = error - self.isLoading = false + self.isLoadingDetails = false } } } - private func convertToDataPoints(from data: [StatsPostViews]) -> [DataPoint] { - // Convert DateComponents to Date using site timezone (similar to how StatsService does it) - var calendar = context.calendar - calendar.timeZone = context.timeZone - - // Convert StatsPostViews to DataPoints using the site timezone - return data.compactMap { postView in - guard let date = calendar.date(from: postView.date) else { return nil } - return DataPoint(date: date, value: postView.viewsCount) - } - } - private var mockDataPoints: [DataPoint] { ChartData.mock( metric: .views, @@ -223,11 +203,14 @@ struct PostStatsDetailsView: View { } private func navigateToLikesList() { - guard let postID = Int(post.postID ?? ""), - let totalLikes = postLikes?.totalCount else { + guard let postID = Int(post.postID ?? "") else { return } - router.navigateToLikesList(siteID: context.siteID, postID: postID, totalLikes: totalLikes) + router.navigateToLikesList( + siteID: context.siteID, + postID: postID, + totalLikes: likes?.totalCount ?? 0 + ) } private func navigateToCommentsList() { @@ -238,6 +221,35 @@ struct PostStatsDetailsView: View { } } +private struct PostDetailsData { + let post: StatsPostDetails.Post? + let views: Int? + let comments: Int? + let dataPoints: [DataPoint] + let weeklyTrends: WeeklyTrendsViewModel + let yearlyTrends: YearlyTrendsViewModel +} + +private func makeData(with details: StatsPostDetails, calendar: Calendar) async -> PostDetailsData { + let dataPoints: [DataPoint] = details.data.compactMap { postView in + guard let date = calendar.date(from: postView.date) else { return nil } + return DataPoint(date: date, value: postView.viewsCount) + } + + let weeklyTrends = WeeklyTrendsViewModel(dataPoints: dataPoints, calendar: calendar) + + let yearlyTrends = YearlyTrendsViewModel(dataPoints: dataPoints, calendar: calendar) + + return PostDetailsData( + post: details.post, + views: details.totalViewsCount, + comments: details.post?.commentCount.flatMap { Int($0) }, + dataPoints: dataPoints, + weeklyTrends: weeklyTrends, + yearlyTrends: yearlyTrends + ) +} + private struct PostStatsMetricsStripView: View { let metrics: SiteMetricsSet let onLikesTapped: (() -> Void)? diff --git a/Modules/Sources/JetpackStats/Views/WeeklyTrendsView.swift b/Modules/Sources/JetpackStats/Views/WeeklyTrendsView.swift index 8e55742943ee..6f529bde82f3 100644 --- a/Modules/Sources/JetpackStats/Views/WeeklyTrendsView.swift +++ b/Modules/Sources/JetpackStats/Views/WeeklyTrendsView.swift @@ -110,7 +110,6 @@ struct WeeklyTrendsView: View { } } -@MainActor final class WeeklyTrendsViewModel: ObservableObject { let weeks: [WeeklyTrendsView.Week] let calendar: Calendar diff --git a/Modules/Sources/JetpackStats/Views/YearlyTrendsView.swift b/Modules/Sources/JetpackStats/Views/YearlyTrendsView.swift index 4af319f834b0..fd392dbfcf2d 100644 --- a/Modules/Sources/JetpackStats/Views/YearlyTrendsView.swift +++ b/Modules/Sources/JetpackStats/Views/YearlyTrendsView.swift @@ -72,7 +72,6 @@ struct YearlyTrendsView: View { } } -@MainActor final class YearlyTrendsViewModel: ObservableObject { let metric: SiteMetric From 030fce8eb624502bece635caca27ef6fc4372906 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Thu, 24 Jul 2025 16:26:40 -0400 Subject: [PATCH 059/349] Fix WeeklyTrendsViewModel not populating empty days --- .../JetpackStats/Views/WeeklyTrendsView.swift | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/Modules/Sources/JetpackStats/Views/WeeklyTrendsView.swift b/Modules/Sources/JetpackStats/Views/WeeklyTrendsView.swift index 6f529bde82f3..7de5fe420e45 100644 --- a/Modules/Sources/JetpackStats/Views/WeeklyTrendsView.swift +++ b/Modules/Sources/JetpackStats/Views/WeeklyTrendsView.swift @@ -173,17 +173,38 @@ final class WeeklyTrendsViewModel: ObservableObject { // Create Week objects with sorted days and calculated average let weeks = weeklyData.map { startDate, days in - let sortedDays = days.sorted { $0.date < $1.date } - let weekTotal = DataPoint.getTotalValue(for: sortedDays, metric: metric) ?? 0 + // Create a dictionary of existing data points by date + var daysByDate: [Date: DataPoint] = [:] + for day in days { + // Normalize to start of day to avoid time component issues + let normalizedDate = calendar.startOfDay(for: day.date) + daysByDate[normalizedDate] = day + } + + // Fill in all 7 days of the week + var completeDays: [DataPoint] = [] + for dayOffset in 0..<7 { + if let date = calendar.date(byAdding: .day, value: dayOffset, to: startDate) { + let normalizedDate = calendar.startOfDay(for: date) + if let existingDay = daysByDate[normalizedDate] { + completeDays.append(existingDay) + } else { + // Add empty day with 0 value + completeDays.append(DataPoint(date: date, value: 0)) + } + } + } + + let weekTotal = DataPoint.getTotalValue(for: completeDays, metric: metric) ?? 0 let averagePerDay: Int - if sortedDays.isEmpty { + if completeDays.isEmpty { averagePerDay = 0 } else if metric.aggregationStrategy == .average { averagePerDay = weekTotal } else { - averagePerDay = weekTotal / sortedDays.count + averagePerDay = weekTotal / completeDays.count } - return WeeklyTrendsView.Week(startDate: startDate, days: sortedDays, averagePerDay: averagePerDay) + return WeeklyTrendsView.Week(startDate: startDate, days: completeDays, averagePerDay: averagePerDay) } // Sort weeks by start date (most recent first) From 0b22024fe92419c1afccbd2e910d751f4cba8492 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Thu, 24 Jul 2025 18:09:06 -0400 Subject: [PATCH 060/349] Cleanup --- ...sDetailsView.swift => PostStatsView.swift} | 69 +++++++++++++----- .../JetpackStats/Screens/StatsMainView.swift | 2 +- .../Sources/JetpackStats/StatsRouter.swift | 56 ++++++++------- .../Views/TopList/TopListItemView.swift | 2 +- .../AbstractPostListViewController.swift | 32 ++++++--- .../Stats/PostStatsViewController.swift | 67 +++++++++++++++++ .../Stats/StatsHostingViewController.swift | 72 +++++++++---------- 7 files changed, 210 insertions(+), 90 deletions(-) rename Modules/Sources/JetpackStats/Screens/{PostStatsDetailsView.swift => PostStatsView.swift} (88%) create mode 100644 WordPress/Classes/ViewRelated/Stats/PostStatsViewController.swift diff --git a/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift b/Modules/Sources/JetpackStats/Screens/PostStatsView.swift similarity index 88% rename from Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift rename to Modules/Sources/JetpackStats/Screens/PostStatsView.swift index 686dabf9a50b..f697f324a062 100644 --- a/Modules/Sources/JetpackStats/Screens/PostStatsDetailsView.swift +++ b/Modules/Sources/JetpackStats/Screens/PostStatsView.swift @@ -2,8 +2,30 @@ import SwiftUI import UIKit import WordPressKit -struct PostStatsDetailsView: View { - let post: TopListData.Post +public struct PostStatsView: View { + public struct PostInfo { + public let title: String + public let postID: String + public let postURL: URL? + public let date: Date? + + public init(title: String, postID: String, postURL: URL? = nil, date: Date? = nil) { + self.title = title + self.postID = postID + self.postURL = postURL + self.date = date + } + + init(from post: TopListData.Post) { + self.title = post.title + self.postID = post.postID ?? "" + self.postURL = post.postURL + self.date = post.date + } + } + + private let post: PostInfo + private let initialDateRange: StatsDateRange? @State private var data: PostDetailsData? @State private var likes: PostLikesData? @@ -14,14 +36,26 @@ struct PostStatsDetailsView: View { @Environment(\.context) private var context @Environment(\.router) private var router - private let initialDateRange: StatsDateRange - init(post: TopListData.Post, dateRange: StatsDateRange) { + self.post = PostInfo(from: post) + self.initialDateRange = dateRange + } + + init(post: PostInfo, dateRange: StatsDateRange) { self.post = post self.initialDateRange = dateRange } - var body: some View { + public static func make(post: PostInfo, context: StatsContext, router: StatsRouter) -> some View { + PostStatsView( + post: post, + dateRange: context.calendar.makeDateRange(for: .last30Days) + ) + .environment(\.context, context) + .environment(\.router, router) + } + + public var body: some View { ScrollView { VStack(spacing: Constants.step2) { contents @@ -72,7 +106,7 @@ struct PostStatsDetailsView: View { StandaloneChartCard( dataPoints: dataPoints, metric: .views, - initialDateRange: initialDateRange, + initialDateRange: dateRange, configuration: .init(minimumGranularity: .day) ) .cardStyle() @@ -143,6 +177,10 @@ struct PostStatsDetailsView: View { // MARK: - Data + private var dateRange: StatsDateRange { + initialDateRange ?? context.calendar.makeDateRange(for: .last30Days) + } + private var metrics: SiteMetricsSet? { guard let data else { return nil @@ -163,7 +201,7 @@ struct PostStatsDetailsView: View { } private func loadPostDetails() async { - guard let postID = Int(post.postID ?? "") else { + guard let postID = Int(post.postID) else { self.error = URLError(.unknown, userInfo: [NSLocalizedDescriptionKey: Strings.Errors.generic]) self.isLoadingDetails = false return @@ -197,13 +235,13 @@ struct PostStatsDetailsView: View { private var mockDataPoints: [DataPoint] { ChartData.mock( metric: .views, - granularity: initialDateRange.dateInterval.preferredGranularity, - range: initialDateRange + granularity: dateRange.dateInterval.preferredGranularity, + range: dateRange ).currentData } private func navigateToLikesList() { - guard let postID = Int(post.postID ?? "") else { + guard let postID = Int(post.postID) else { return } router.navigateToLikesList( @@ -214,7 +252,7 @@ struct PostStatsDetailsView: View { } private func navigateToCommentsList() { - guard let postID = Int(post.postID ?? "") else { + guard let postID = Int(post.postID) else { return } router.navigateToCommentsList(siteID: context.siteID, postID: postID) @@ -403,17 +441,14 @@ private struct PostLikesStripView: View { #Preview { NavigationStack { - PostStatsDetailsView( + PostStatsView( post: .init( title: "Matter Smart Home Protocol Still Doesn't Matter: A Year Later", postID: "12345", postURL: URL(string: "example.com"), - date: .now, - type: "post", - author: nil, - metrics: .init(views: 45892, likes: 26, comments: 487) + date: .now ), - dateRange: Calendar.demo.makeDateRange(for: .thisYear) + dateRange: Calendar.demo.makeDateRange(for: .last30Days) ) .environment(\.context, StatsContext.demo) } diff --git a/Modules/Sources/JetpackStats/Screens/StatsMainView.swift b/Modules/Sources/JetpackStats/Screens/StatsMainView.swift index fd8a14700d9a..1d595e1de218 100644 --- a/Modules/Sources/JetpackStats/Screens/StatsMainView.swift +++ b/Modules/Sources/JetpackStats/Screens/StatsMainView.swift @@ -55,7 +55,7 @@ private struct PreviewStatsMainView: UIViewControllerRepresentable { func makeUIViewController(context: Context) -> UINavigationController { let navigationController = UINavigationController() - let router = StatsRouter(navigationController: navigationController) + let router = StatsRouter(viewController: navigationController, factory: MockStatsRouterScreenFactory()) let view = StatsMainView(context: .demo, router: router) let hostingController = UIHostingController(rootView: view) navigationController.viewControllers = [hostingController] diff --git a/Modules/Sources/JetpackStats/StatsRouter.swift b/Modules/Sources/JetpackStats/StatsRouter.swift index 6f8dcabd9678..55a1cad35c88 100644 --- a/Modules/Sources/JetpackStats/StatsRouter.swift +++ b/Modules/Sources/JetpackStats/StatsRouter.swift @@ -1,52 +1,60 @@ import SwiftUI import UIKit -public protocol StatsRouterDelegate: AnyObject { - func makeLikesListViewController(siteID: Int, postID: Int, totalLikes: Int) -> UIViewController? - func makeCommentsListViewController(siteID: Int, postID: Int) -> UIViewController? +@MainActor +public protocol StatsRouterScreenFactory: AnyObject { + func makeLikesListViewController(siteID: Int, postID: Int, totalLikes: Int) -> UIViewController + func makeCommentsListViewController(siteID: Int, postID: Int) -> UIViewController } public final class StatsRouter: @unchecked Sendable { - public weak var navigationController: UINavigationController? - private weak var _delegate: StatsRouterDelegate? - - public var delegate: StatsRouterDelegate? { - get { _delegate } - set { _delegate = newValue } + @MainActor + var navigationController: UINavigationController? { + (viewController as? UINavigationController) ?? viewController?.navigationController } - public init(navigationController: UINavigationController? = nil, delegate: StatsRouterDelegate? = nil) { - self.navigationController = navigationController - self._delegate = delegate + public weak var viewController: UIViewController? + + let factory: StatsRouterScreenFactory + + public init(viewController: UIViewController? = nil, factory: StatsRouterScreenFactory) { + self.viewController = viewController + self.factory = factory } @MainActor - public func navigate(to view: Content) { + func navigate(to view: Content) { let viewController = UIHostingController(rootView: view) navigationController?.pushViewController(viewController, animated: true) } @MainActor - public func navigateToLikesList(siteID: Int, postID: Int, totalLikes: Int) { - guard let viewController = delegate?.makeLikesListViewController(siteID: siteID, postID: postID, totalLikes: totalLikes) else { - return - } - navigationController?.pushViewController(viewController, animated: true) + func navigateToLikesList(siteID: Int, postID: Int, totalLikes: Int) { + let likesVC = factory.makeLikesListViewController(siteID: siteID, postID: postID, totalLikes: totalLikes) + navigationController?.pushViewController(likesVC, animated: true) } @MainActor - public func navigateToCommentsList(siteID: Int, postID: Int) { - guard let viewController = delegate?.makeCommentsListViewController(siteID: siteID, postID: postID) else { - return - } - navigationController?.pushViewController(viewController, animated: true) + func navigateToCommentsList(siteID: Int, postID: Int) { + let commenstVC = factory.makeCommentsListViewController(siteID: siteID, postID: postID) + navigationController?.pushViewController(commenstVC, animated: true) + } +} + +class MockStatsRouterScreenFactory: StatsRouterScreenFactory { + func makeCommentsListViewController(siteID: Int, postID: Int) -> UIViewController { + UIHostingController(rootView: Text(Strings.Errors.generic)) + } + + func makeLikesListViewController(siteID: Int, postID: Int, totalLikes: Int) -> UIViewController { + UIHostingController(rootView: Text(Strings.Errors.generic)) } } // MARK: - Environment Key private struct StatsRouterKey: EnvironmentKey { - static let defaultValue = StatsRouter() + static let defaultValue = StatsRouter(factory: MockStatsRouterScreenFactory()) } extension EnvironmentValues { diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift index b9d90fab0e7e..ecfc7bee5637 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift @@ -87,7 +87,7 @@ private extension TopListItemView { func navigateToDetails() { switch currentItem { case let post as TopListData.Post: - let detailsView = PostStatsDetailsView(post: post, dateRange: dateRange) + let detailsView = PostStatsView(post: post, dateRange: dateRange) .environment(\.context, context) .environment(\.router, router) router.navigate(to: detailsView) diff --git a/WordPress/Classes/ViewRelated/Post/Controllers/AbstractPostListViewController.swift b/WordPress/Classes/ViewRelated/Post/Controllers/AbstractPostListViewController.swift index bde8ccd2c641..8a91716eb9d7 100644 --- a/WordPress/Classes/ViewRelated/Post/Controllers/AbstractPostListViewController.swift +++ b/WordPress/Classes/ViewRelated/Post/Controllers/AbstractPostListViewController.swift @@ -7,6 +7,8 @@ import WordPressFlux import WordPressUI import WordPressKit import Combine +import SwiftUI +import JetpackStats class AbstractPostListViewController: UIViewController, WPContentSyncHelperDelegate, @@ -740,17 +742,29 @@ class AbstractPostListViewController: UIViewController, return } - SiteStatsInformation.sharedInstance.siteTimeZone = blog.timeZone - SiteStatsInformation.sharedInstance.oauth2Token = blog.authToken - SiteStatsInformation.sharedInstance.siteID = blog.dotComID + if FeatureFlag.newStats.enabled { + // Create the view controller + let statsViewController = PostStatsViewController(post: post) - guard let postURL = post.permaLink.flatMap(URL.init) else { - return wpAssertionFailure("permalink missing or invalid") + // Present modally in a navigation controller + let navController = UINavigationController(rootViewController: statsViewController) + navController.modalPresentationStyle = .pageSheet + + present(navController, animated: true) + } else { + // Use legacy stats view + SiteStatsInformation.sharedInstance.siteTimeZone = blog.timeZone + SiteStatsInformation.sharedInstance.oauth2Token = blog.authToken + SiteStatsInformation.sharedInstance.siteID = blog.dotComID + + guard let postURL = post.permaLink.flatMap(URL.init) else { + return wpAssertionFailure("permalink missing or invalid") + } + let postStatsTableViewController = PostStatsTableViewController.withJPBannerForBlog(postID: postID, + postTitle: post.titleForDisplay(), + postURL: postURL) + navigationController?.pushViewController(postStatsTableViewController, animated: true) } - let postStatsTableViewController = PostStatsTableViewController.withJPBannerForBlog(postID: postID, - postTitle: post.titleForDisplay(), - postURL: postURL) - navigationController?.pushViewController(postStatsTableViewController, animated: true) } @objc func copyPostLink(_ post: AbstractPost) { diff --git a/WordPress/Classes/ViewRelated/Stats/PostStatsViewController.swift b/WordPress/Classes/ViewRelated/Stats/PostStatsViewController.swift new file mode 100644 index 000000000000..52cf5de9954e --- /dev/null +++ b/WordPress/Classes/ViewRelated/Stats/PostStatsViewController.swift @@ -0,0 +1,67 @@ +import UIKit +import SwiftUI +import JetpackStats +import WordPressKit +import WordPressUI +import WordPressShared + +/// View controller that displays post statistics using the new SwiftUI PostStatsView +final class PostStatsViewController: UIViewController { + private let post: AbstractPost + + init(post: AbstractPost) { + self.post = post + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .systemBackground + + setupStatsView() + setupNavigationBar() + } + + private func setupStatsView() { + guard let context = StatsContext(blog: post.blog), + let postID = post.postID?.intValue else { + return + } + let info = PostStatsView.PostInfo( + title: post.titleForDisplay() ?? "", + postID: String(postID), + postURL: post.permaLink.flatMap(URL.init), + date: post.dateCreated + ) + let statsView = PostStatsView.make( + post: info, + context: context, + router: StatsRouter(viewController: self) + ) + let hostingController = UIHostingController(rootView: statsView) + + addChild(hostingController) + view.addSubview(hostingController.view) + hostingController.view.pinEdges() + hostingController.didMove(toParent: self) + } + + private func setupNavigationBar() { + if presentingViewController != nil { + navigationItem.rightBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .done, + target: self, + action: #selector(dismissViewController) + ) + } + } + + @objc private func dismissViewController() { + presentingViewController?.dismiss(animated: true) + } +} diff --git a/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift b/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift index 5e021e6b4fbb..ccda1b40284d 100644 --- a/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift +++ b/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift @@ -2,6 +2,7 @@ import UIKit import SwiftUI import JetpackStats import WordPressKit +import WordPressShared /// A UIViewController wrapper for the new SwiftUI StatsMainView class StatsHostingViewController: UIViewController { @@ -45,42 +46,23 @@ class StatsHostingViewController: UIViewController { } private func setupStatsView() { - guard let siteID = blog.dotComID?.intValue, - let api = blog.account?.wordPressComRestApi else { + guard var context = StatsContext(blog: blog) else { showErrorView() return } - let siteTimezone = blog.timeZone ?? TimeZone.current - - // Create the context - let context: StatsContext if isUsingMockService { // For mock service, we need to use the internal initializer // Since we can't access it directly, we'll use the demo context context = StatsContext.demo - } else { - // For real service, use the public initializer - context = StatsContext(timeZone: siteTimezone, siteID: siteID, api: api) } - // Create the router with reference to navigation controller - let router = StatsRouter(navigationController: navigationController, delegate: self) - - // Create the SwiftUI view - let statsView = StatsMainView(context: context, router: router) + let statsView = StatsMainView(context: context, router: StatsRouter(viewController: self)) let hostingController = UIHostingController(rootView: AnyView(statsView)) - // Add as child view controller addChild(hostingController) view.addSubview(hostingController.view) - hostingController.view.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - hostingController.view.topAnchor.constraint(equalTo: view.topAnchor), - hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), - hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), - hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor) - ]) + hostingController.view.pinEdges() hostingController.didMove(toParent: self) self.hostingController = hostingController @@ -171,7 +153,6 @@ class StatsHostingViewController: UIViewController { } } -// MARK: - Presentation extension StatsHostingViewController { static func show(for blog: Blog, from viewController: UIViewController) { let statsVC = StatsHostingViewController(blog: blog) @@ -181,30 +162,45 @@ extension StatsHostingViewController { } } -// MARK: - StatsRouterDelegate -extension StatsHostingViewController: StatsRouterDelegate { - func makeLikesListViewController(siteID: Int, postID: Int, totalLikes: Int) -> UIViewController? { - guard let siteID = blog.dotComID else { +extension StatsContext { + init?(blog: Blog) { + guard let siteID = blog.dotComID?.intValue, + let api = blog.account?.wordPressComRestApi else { + wpAssertionFailure("required context missing") return nil } - - return StatsLikesListViewController( + self.init( + timeZone: blog.timeZone ?? .current, siteID: siteID, + api: api + ) + } +} + +extension StatsRouter { + @MainActor + convenience init(viewController: UIViewController) { + self.init( + viewController: viewController, + factory: JetpackAppStatsRouterScreenFactory() + ) + } +} + +/// Shared router implementation for Jetpack app stats navigation +private final class JetpackAppStatsRouterScreenFactory: StatsRouterScreenFactory { + func makeLikesListViewController(siteID: Int, postID: Int, totalLikes: Int) -> UIViewController { + StatsLikesListViewController( + siteID: siteID as NSNumber, postID: NSNumber(value: postID), totalLikes: totalLikes ) } - func makeCommentsListViewController(siteID: Int, postID: Int) -> UIViewController? { - guard let siteID = blog.dotComID else { - return nil - } - - let commentsVC = ReaderCommentsViewController( + func makeCommentsListViewController(siteID: Int, postID: Int) -> UIViewController { + ReaderCommentsViewController( postID: NSNumber(value: postID), - siteID: siteID + siteID: siteID as NSNumber ) - commentsVC.source = .postDetails - return commentsVC } } From 9db079ebc2ce5293711b9fc6204b82b158b95ec7 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Thu, 24 Jul 2025 18:19:15 -0400 Subject: [PATCH 061/349] Fix typos --- Modules/Sources/JetpackStats/StatsRouter.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Modules/Sources/JetpackStats/StatsRouter.swift b/Modules/Sources/JetpackStats/StatsRouter.swift index 55a1cad35c88..0ae41e651568 100644 --- a/Modules/Sources/JetpackStats/StatsRouter.swift +++ b/Modules/Sources/JetpackStats/StatsRouter.swift @@ -36,8 +36,8 @@ public final class StatsRouter: @unchecked Sendable { @MainActor func navigateToCommentsList(siteID: Int, postID: Int) { - let commenstVC = factory.makeCommentsListViewController(siteID: siteID, postID: postID) - navigationController?.pushViewController(commenstVC, animated: true) + let commentsVC = factory.makeCommentsListViewController(siteID: siteID, postID: postID) + navigationController?.pushViewController(commentsVC, animated: true) } } From 81aeb38cf99f577c630478d24e68ea9389c8c70d Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Thu, 24 Jul 2025 19:43:01 -0400 Subject: [PATCH 062/349] Update WordPressKit --- Modules/Package.resolved | 4 ++-- Modules/Package.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Modules/Package.resolved b/Modules/Package.resolved index 3f7c942a38c9..0c3a0a72b4ed 100644 --- a/Modules/Package.resolved +++ b/Modules/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "f282a9bbd2eb46463022d668957f80330ad95f693c8bec3425a741e12e4b2f39", + "originHash" : "e3e823d31b833eca898ce70bd04f75d481070d36c5823cfffae0afc36ad263fc", "pins" : [ { "identity" : "alamofire", @@ -381,7 +381,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/wordpress-mobile/WordPressKit-iOS", "state" : { - "revision" : "c31ddf14716f5ce5d810a21d1cacb5c2da8709d5" + "revision" : "2613f213b1a958266f88fa7e99d990292096bffb" } }, { diff --git a/Modules/Package.swift b/Modules/Package.swift index 5b169e06a6c7..7efc5411c3df 100644 --- a/Modules/Package.swift +++ b/Modules/Package.swift @@ -50,7 +50,7 @@ let package = Package( .package(url: "https://github.com/wordpress-mobile/NSURL-IDN", revision: "b34794c9a3f32312e1593d4a3d120572afa0d010"), .package( url: "https://github.com/wordpress-mobile/WordPressKit-iOS", - revision: "c31ddf14716f5ce5d810a21d1cacb5c2da8709d5" // see wpios-edition branch + revision: "2613f213b1a958266f88fa7e99d990292096bffb" // see wpios-edition branch ), .package(url: "https://github.com/zendesk/support_sdk_ios", from: "8.0.3"), // We can't use wordpress-rs branches nor commits here. Only tags work. From 387e51a743f73a558f35985b326bf9740207a0b2 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 25 Jul 2025 08:27:33 -0400 Subject: [PATCH 063/349] Add initial support for Archive items --- .../JetpackStats/Screens/TrafficTabView.swift | 25 ++-- .../Services/Data/TopListChartData.swift | 65 ++++++++++ .../Services/Data/TopListData.swift | 16 +++ .../Services/Data/TopListItemType.swift | 7 +- .../Services/Mocks/MockStatsService.swift | 39 ++++++ .../JetpackStats/Services/StatsService.swift | 42 ++++++- Modules/Sources/JetpackStats/Strings.swift | 6 + .../Views/LegacyFloatingDateControl.swift | 2 +- .../Rows/TopListArchiveItemRowView.swift | 22 ++++ .../Rows/TopListArchiveSectionRowView.swift | 37 ++++++ .../Views/TopList/TopListItemView.swift | 4 + .../Views/TopList/TopListItemsView.swift | 119 ++++++++++++++++-- .../Views/TopList/TopListMetricsView.swift | 8 +- 13 files changed, 361 insertions(+), 31 deletions(-) create mode 100644 Modules/Sources/JetpackStats/Views/TopList/Rows/TopListArchiveItemRowView.swift create mode 100644 Modules/Sources/JetpackStats/Views/TopList/Rows/TopListArchiveSectionRowView.swift diff --git a/Modules/Sources/JetpackStats/Screens/TrafficTabView.swift b/Modules/Sources/JetpackStats/Screens/TrafficTabView.swift index 7061a594c4e2..1317fa27df80 100644 --- a/Modules/Sources/JetpackStats/Screens/TrafficTabView.swift +++ b/Modules/Sources/JetpackStats/Screens/TrafficTabView.swift @@ -29,15 +29,13 @@ struct TrafficTabView: View { } } .background(Constants.Colors.background) - .toolbar { - if #available(iOS 26, *) { - normalModeToolbarContent - } - } +// .toolbar { +// if #available(iOS 26, *) { +// normalModeToolbarContent +// } +// } .safeAreaInset(edge: .bottom) { - if #unavailable(iOS 26) { - LegacyFloatingDateControl(dateRange: $dateRange) - } + LegacyFloatingDateControl(dateRange: $dateRange) } .sheet(isPresented: $isShowingCustomRangePicker) { CustomDateRangePicker(dateRange: $dateRange) @@ -59,16 +57,17 @@ struct TrafficTabView: View { } } + #warning("TEMP") private func configureViewModels() { guard viewModels.isEmpty else { return } viewModels = [ - ChartCardViewModel( - metrics: context.service.supportedMetrics, - dateRange: dateRange, - service: context.service - ), +// ChartCardViewModel( +// metrics: context.service.supportedMetrics, +// dateRange: dateRange, +// service: context.service +// ), TopListCardViewModel( selection: .init(item: .postsAndPages, metric: .views), dateRange: dateRange, diff --git a/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift b/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift index aac38573ce68..8c742fd8b456 100644 --- a/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift +++ b/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift @@ -88,6 +88,8 @@ extension TopListChartData { return mockSearchTerms(metric: metric, count: count) case .videos: return mockVideos(metric: metric, count: count) + case .archive: + return mockArchive(metric: metric, count: count) } } @@ -281,6 +283,58 @@ extension TopListChartData { ) } } + + private static func mockArchive(metric: SiteMetric, count: Int) -> [any TopListItem] { + // Create mock archive sections + let archiveSections = [ + ("pages", [ + ("/about/", 2500), + ("/contact/", 1800), + ("/privacy-policy/", 1200), + ("/terms-of-service/", 800), + ("/faq/", 600) + ]), + ("categories", [ + ("/category/technology/", 3200), + ("/category/design/", 2800), + ("/category/business/", 2400), + ("/category/lifestyle/", 1600) + ]), + ("tags", [ + ("/tag/swift/", 2100), + ("/tag/ios/", 1900), + ("/tag/swiftui/", 1700), + ("/tag/mobile/", 1400) + ]), + ("archives", [ + ("/2024/01/", 1500), + ("/2023/12/", 1300), + ("/2023/11/", 1100), + ("/2023/10/", 900) + ]) + ] + + return archiveSections.prefix(count).map { sectionData in + let sectionName = sectionData.0 + let items = sectionData.1.map { itemData in + let metrics = createMetrics(baseValue: itemData.1, metric: metric) + return TopListData.ArchiveItem( + href: "https://example.com\(itemData.0)", + value: itemData.0, + metrics: metrics + ) + } + + // Calculate total views for the section + let totalViews = items.reduce(0) { $0 + ($1.metrics[metric] ?? 0) } + + return TopListData.ArchiveSection( + sectionName: sectionName, + items: items, + metrics: SiteMetricsSet(views: totalViews) + ) + } + } private static func createMetrics(baseValue: Int, metric: SiteMetric) -> SiteMetricsSet { // Add some variation to make it more realistic @@ -324,6 +378,17 @@ extension TopListChartData { let trendFactor = Double.random(in: 0.7...1.3) let currentValue = item.metrics[metric] ?? 0 item.metrics[metric] = Int(Double(currentValue) * trendFactor) + + // Special handling for archive sections - update child items too + if var archiveSection = item as? TopListData.ArchiveSection { + archiveSection.items = archiveSection.items.map { archiveItem in + var mutableItem = archiveItem + let itemCurrentValue = mutableItem.metrics[metric] ?? 0 + mutableItem.metrics[metric] = Int(Double(itemCurrentValue) * trendFactor) + return mutableItem + } + return archiveSection + } return item } diff --git a/Modules/Sources/JetpackStats/Services/Data/TopListData.swift b/Modules/Sources/JetpackStats/Services/Data/TopListData.swift index fca6bbe70592..240a97044dfa 100644 --- a/Modules/Sources/JetpackStats/Services/Data/TopListData.swift +++ b/Modules/Sources/JetpackStats/Services/Data/TopListData.swift @@ -80,4 +80,20 @@ extension TopListData { var id: String { postId } } + + struct ArchiveItem: Codable, TopListItem { + let href: String + let value: String + var metrics: SiteMetricsSet + + var id: String { href } + } + + struct ArchiveSection: Codable, TopListItem { + let sectionName: String + var items: [ArchiveItem] + var metrics: SiteMetricsSet + + var id: String { sectionName } + } } diff --git a/Modules/Sources/JetpackStats/Services/Data/TopListItemType.swift b/Modules/Sources/JetpackStats/Services/Data/TopListItemType.swift index 949b2d88be56..f40ac844160e 100644 --- a/Modules/Sources/JetpackStats/Services/Data/TopListItemType.swift +++ b/Modules/Sources/JetpackStats/Services/Data/TopListItemType.swift @@ -9,6 +9,7 @@ enum TopListItemType: Identifiable, CaseIterable, Sendable { case fileDownloads case searchTerms case videos + case archive var id: TopListItemType { self } @@ -22,12 +23,13 @@ enum TopListItemType: Identifiable, CaseIterable, Sendable { case .fileDownloads: Strings.SiteDataTypes.fileDownloads case .searchTerms: Strings.SiteDataTypes.searchTerms case .videos: Strings.SiteDataTypes.videos + case .archive: Strings.SiteDataTypes.archive } } var systemImage: String { switch self { - case .postsAndPages: "doc.text" + case .postsAndPages: "text.page" case .referrers: "link" case .locations: "map" case .authors: "person.2" @@ -35,6 +37,7 @@ enum TopListItemType: Identifiable, CaseIterable, Sendable { case .fileDownloads: "arrow.down.circle" case .searchTerms: "magnifyingglass" case .videos: "play.rectangle" + case .archive: "rectangle.and.text.magnifyingglass" } } @@ -52,6 +55,6 @@ enum TopListItemType: Identifiable, CaseIterable, Sendable { } static let secondaryItems: Set = [ - .externalLinks, .fileDownloads, .searchTerms, .videos + .externalLinks, .fileDownloads, .searchTerms, .videos, .archive ] } diff --git a/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift b/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift index 57b28cbe72a9..de11fb67125b 100644 --- a/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift +++ b/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift @@ -13,6 +13,7 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { nonisolated func getSupportedMetrics(for item: TopListItemType) -> [SiteMetric] { switch item { case .postsAndPages: [.views, .visitors, .comments, .likes] + case .archive: [.views] case .referrers: [.views, .visitors] case .locations: [.views, .visitors] case .authors: [.views, .comments, .likes] @@ -184,6 +185,8 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { switch dataType { case .postsAndPages: fileName = "postsAndPages" + case .archive: + return generateMockArchiveData() case .referrers: fileName = "referrers" case .locations: @@ -242,6 +245,9 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { case .postsAndPages: let posts = try decoder.decode([TopListData.Post].self, from: data) return posts + case .archive: + // This case is handled by generateMockArchiveData + return [] } } catch { print("Failed to load \(fileName).json: \(error)") @@ -303,6 +309,8 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { switch dataType { case .postsAndPages: fileName = "historical-postsAndPages" + case .archive: + return generateMockArchiveData() case .referrers: fileName = "historical-referrers" case .locations: @@ -361,6 +369,9 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { case .postsAndPages: let posts = try decoder.decode([TopListData.Post].self, from: data) return posts + case .archive: + // This case is handled by generateMockArchiveData + return [] } } catch { print("Failed to load \(fileName).json: \(error)") @@ -536,4 +547,32 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { dailyTopListData[dataType] = typeData } } + + /// Generates mock archive data with expandable sections + private func generateMockArchiveData() -> [any TopListItem] { + // Create mock archive sections based on the example JSON structure + let otherSection = TopListData.ArchiveSection( + sectionName: "other", + items: [ + TopListData.ArchiveItem(href: "http://example.com/wp-admin/admin.php?page=stats", value: "/wp-admin/admin.php?page=stats", metrics: SiteMetricsSet(views: 10)), + TopListData.ArchiveItem(href: "http://example.com/wp-admin/", value: "/wp-admin/", metrics: SiteMetricsSet(views: 4)), + TopListData.ArchiveItem(href: "http://example.com/wp-admin/edit.php", value: "/wp-admin/edit.php", metrics: SiteMetricsSet(views: 4)), + TopListData.ArchiveItem(href: "http://example.com/wp-admin/index.php", value: "/wp-admin/index.php", metrics: SiteMetricsSet(views: 2)), + TopListData.ArchiveItem(href: "http://example.com/wp-admin/revision.php?revision=12345", value: "/wp-admin/revision.php?revision=12345", metrics: SiteMetricsSet(views: 2)) + ], + metrics: SiteMetricsSet(views: 25) // Total views for the section + ) + + let authorSection = TopListData.ArchiveSection( + sectionName: "author", + items: [ + TopListData.ArchiveItem(href: "http://example.com/author/johndoe/", value: "johndoe", metrics: SiteMetricsSet(views: 31)), + TopListData.ArchiveItem(href: "http://example.com/author/janedoe/", value: "janedoe", metrics: SiteMetricsSet(views: 5)), + TopListData.ArchiveItem(href: "http://example.com/author/testuser/", value: "testuser", metrics: SiteMetricsSet(views: 2)) + ], + metrics: SiteMetricsSet(views: 40) // Total views for the section + ) + + return [otherSection, authorSection] + } } diff --git a/Modules/Sources/JetpackStats/Services/StatsService.swift b/Modules/Sources/JetpackStats/Services/StatsService.swift index ab07c1744545..1e40712c9c25 100644 --- a/Modules/Sources/JetpackStats/Services/StatsService.swift +++ b/Modules/Sources/JetpackStats/Services/StatsService.swift @@ -24,13 +24,14 @@ actor StatsService: StatsServiceProtocol { ] let supportedItems: [TopListItemType] = [ - .postsAndPages, .referrers, .locations, .authors, .externalLinks, + .postsAndPages, .archive, .referrers, .locations, .authors, .externalLinks, .fileDownloads, .searchTerms, .videos ] nonisolated func getSupportedMetrics(for item: TopListItemType) -> [SiteMetric] { switch item { case .postsAndPages: [.views] + case .archive: [.views] case .referrers: [.views] case .locations: [.views] case .authors: [.views] @@ -180,6 +181,15 @@ actor StatsService: StatsServiceProtocol { default: throw StatsServiceError.unavailable } + + case .archive: + switch metric { + case .views: + let data = try await getData(StatsArchiveTimeIntervalData.self) + return mapArchiveToTopListData(data) + default: + throw StatsServiceError.unavailable + } } } @@ -422,6 +432,36 @@ actor StatsService: StatsServiceProtocol { } 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 { diff --git a/Modules/Sources/JetpackStats/Strings.swift b/Modules/Sources/JetpackStats/Strings.swift index 49184c025a23..7a8a4501b9e1 100644 --- a/Modules/Sources/JetpackStats/Strings.swift +++ b/Modules/Sources/JetpackStats/Strings.swift @@ -46,6 +46,7 @@ enum Strings { enum SiteDataTypes { static let postsAndPages = AppLocalizedString("jetpackStats.siteDataTypes.postsAndPages", value: "Posts & Pages", comment: "Posts and pages data type") + static let archive = AppLocalizedString("jetpackStats.siteDataTypes.archive", value: "Archive", comment: "Archive data type") static let posts = AppLocalizedString("jetpackStats.siteDataTypes.posts", value: "Posts", comment: "Posts data type") static let pages = AppLocalizedString("jetpackStats.siteDataTypes.pages", value: "Pages", comment: "Pages data type") static let authors = AppLocalizedString("jetpackStats.siteDataTypes.authors", value: "Authors", comment: "Authors data type") @@ -115,6 +116,11 @@ enum Strings { } } + enum ArchiveSections { + static let author = AppLocalizedString("jetpackStats.archiveSections.author", value: "Authors", comment: "Archive section for authors") + static let other = AppLocalizedString("jetpackStats.archiveSections.other", value: "Other", comment: "Archive section for other items") + } + enum PostDetails { static let title = AppLocalizedString("jetpackStats.postDetails.title", value: "Post Stats", comment: "Navigation title") static func published(_ date: String) -> String { diff --git a/Modules/Sources/JetpackStats/Views/LegacyFloatingDateControl.swift b/Modules/Sources/JetpackStats/Views/LegacyFloatingDateControl.swift index ad7291abb585..cc1db9d53323 100644 --- a/Modules/Sources/JetpackStats/Views/LegacyFloatingDateControl.swift +++ b/Modules/Sources/JetpackStats/Views/LegacyFloatingDateControl.swift @@ -48,11 +48,11 @@ struct LegacyFloatingDateControl: View { dateRangeButtonContent .contentShape(Rectangle()) .frame(height: buttonHeight) + .floatingStyle() } .tint(Color.primary) .menuOrder(.fixed) .buttonStyle(.plain) - .floatingStyle() } private var dateRangeButtonContent: some View { diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListArchiveItemRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListArchiveItemRowView.swift new file mode 100644 index 000000000000..b13d755cee92 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListArchiveItemRowView.swift @@ -0,0 +1,22 @@ +import SwiftUI + +struct TopListArchiveItemRowView: View { + let item: TopListData.ArchiveItem + let showDetails: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + ZStack(alignment: .leading) { + // Ensure stable height + Text(item.value) + .lineLimit(1, reservesSpace: true) + .opacity(0) + Text(item.value) + } + .font(.callout) + .foregroundColor(.primary) + .lineLimit(1) + .padding(.trailing, 4) + } + } +} \ No newline at end of file diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListArchiveSectionRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListArchiveSectionRowView.swift new file mode 100644 index 000000000000..de8c02a4c161 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListArchiveSectionRowView.swift @@ -0,0 +1,37 @@ +import SwiftUI + +struct TopListArchiveSectionRowView: View { + let item: TopListData.ArchiveSection + let showDetails: Bool + var isExpanded: Bool = false + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Image(systemName: isExpanded ? "chevron.down" : "chevron.forward") + .frame(width: 16) + .font(.caption) + .foregroundColor(.secondary) + .animation(.none, value: isExpanded) + + Text(localizedSectionName) + .font(.callout) + .foregroundColor(.primary) + .lineLimit(1) + } + .padding(.trailing, 4) + } + } + + private var localizedSectionName: String { + switch item.sectionName.lowercased() { + case "author": + return Strings.ArchiveSections.author + case "other": + return Strings.ArchiveSections.other + default: + // Fallback to capitalized for any unknown sections + return item.sectionName.capitalized + } + } +} diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift index ecfc7bee5637..41cbd8199b15 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift @@ -44,6 +44,10 @@ struct TopListItemView: View { TopListSearchTermRowView(item: searchTerm, showDetails: showDetails) case let video as TopListData.Video: TopListVideoRowView(item: video, showDetails: showDetails) + case let archiveItem as TopListData.ArchiveItem: + TopListArchiveItemRowView(item: archiveItem, showDetails: showDetails) + case let archiveSection as TopListData.ArchiveSection: + TopListArchiveSectionRowView(item: archiveSection, showDetails: showDetails) default: let _ = assertionFailure("unsupported item: \(currentItem)") EmptyView() diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift index 2986164daf75..c860b8996ffe 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift @@ -5,23 +5,120 @@ struct TopListItemsView: View { let itemLimit: Int let dateRange: StatsDateRange var showDetails = true + + @State private var expandedSections: Set = [] var body: some View { VStack(spacing: Constants.step1 / 2) { ForEach(data.items.prefix(itemLimit)) { item in - TopListItemView( - currentItem: item.current, - previousItem: item.previous, - metric: data.metric, - maxValue: data.maxValue, - showDetails: showDetails, - dateRange: dateRange - ) - .transition(.move(edge: .leading) - .combined(with: .scale(scale: 0.75)) - .combined(with: .opacity)) + if let archiveSection = item.current as? TopListData.ArchiveSection { + // Archive section with expandable items + VStack(spacing: Constants.step1 / 2) { + // Section header + Button { + withAnimation(.easeInOut(duration: 0.25)) { + toggleSection(archiveSection.id) + } + } label: { + ArchiveSectionItemView( + section: archiveSection, + previousItem: item.previous, + metric: data.metric, + maxValue: data.maxValue, + dateRange: dateRange, + isExpanded: expandedSections.contains(archiveSection.id) + ) + } + .buttonStyle(PlainButtonStyle()) + + // Expandable items + if expandedSections.contains(archiveSection.id) { + VStack(spacing: Constants.step1 / 2) { + ForEach(archiveSection.items) { archiveItem in + TopListItemView( + currentItem: archiveItem, + previousItem: nil, + metric: data.metric, + maxValue: data.maxValue, + showDetails: showDetails, + dateRange: dateRange + ) + .padding(.leading, Constants.step4) + .transition(.asymmetric( + insertion: .opacity.combined(with: .move(edge: .top)), + removal: .opacity.combined(with: .move(edge: .top)) + )) + } + } + } + } + } else { + // Regular item + TopListItemView( + currentItem: item.current, + previousItem: item.previous, + metric: data.metric, + maxValue: data.maxValue, + showDetails: showDetails, + dateRange: dateRange + ) + .transition(.move(edge: .leading) + .combined(with: .scale(scale: 0.75)) + .combined(with: .opacity)) + } } } .animation(.spring, value: ObjectIdentifier(data)) + .animation(.easeInOut(duration: 0.25), value: expandedSections) + } + + private func toggleSection(_ sectionId: String) { + if expandedSections.contains(sectionId) { + expandedSections.remove(sectionId) + } else { + expandedSections.insert(sectionId) + } + } +} + +// Custom view for archive section header that can show expanded state +private struct ArchiveSectionItemView: View { + let section: TopListData.ArchiveSection + let previousItem: (any TopListItem)? + let metric: SiteMetric + let maxValue: Int + let dateRange: StatsDateRange + let isExpanded: Bool + + @Environment(\.router) private var router + @Environment(\.context) private var context + + var body: some View { + HStack(spacing: 0) { + TopListArchiveSectionRowView( + item: section, + showDetails: false, + isExpanded: isExpanded + ) + + Spacer(minLength: 4) + + TopListMetricsView( + currentValue: section.metrics[metric] ?? 0, + previousValue: previousItem?.metrics[metric], + metric: metric, + showDetails: false, + showChevron: false + ) + } + .padding(.vertical, 7) + .background( + TopListItemBarBackground( + value: section.metrics[metric] ?? 0, + maxValue: maxValue, + barColor: metric.primaryColor + ) + .padding(.horizontal, -(Constants.step2 / 2)) + ) } } diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListMetricsView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListMetricsView.swift index e70079a73820..88e5ff81d4e7 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListMetricsView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListMetricsView.swift @@ -15,9 +15,11 @@ struct TopListMetricsView: View { .foregroundColor(.primary) .contentTransition(.numericText()) - Image(systemName: "chevron.forward") - .font(.caption2.weight(.bold)) - .foregroundStyle(Color(.tertiaryLabel)) + if hasDetails { + Image(systemName: "chevron.forward") + .font(.caption2.weight(.bold)) + .foregroundStyle(Color(.tertiaryLabel)) + } } if showDetails, let trend { Text(trend.formattedTrend) From c0baf7d822bbd5686490048a53f766b565f6afc7 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 25 Jul 2025 08:33:40 -0400 Subject: [PATCH 064/349] Dot not attempt to fetch previos interval for archive --- .../Cards/TopListCardViewModel.swift | 21 ++++++++++++------- .../Views/TopList/TopListItemsView.swift | 2 +- .../Views/TopList/TopListMetricsView.swift | 2 +- 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift b/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift index 7e9c6f2a967c..27c16eebc347 100644 --- a/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift +++ b/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift @@ -124,25 +124,30 @@ final class TopListCardViewModel: ObservableObject, TrafficCardViewModel { private func getTopListData(for selection: Selection, dateRange: StatsDateRange) async throws -> TopListChartData { let granularity = dateRange.dateInterval.preferredGranularity - // Fetch both current and previous period data concurrently + // Fetch current data async let currentTask = service.getTopListData( selection.item, metric: selection.metric, interval: dateRange.dateInterval, granularity: granularity ) - async let previousTask = service.getTopListData( - selection.item, - metric: selection.metric, - interval: dateRange.effectiveComparisonInterval, - granularity: granularity - ) + + // Fetch previous data only for items that support it + async let previousTask: TopListData? = { + guard selection.item != .archive else { return nil } + return try await service.getTopListData( + selection.item, + metric: selection.metric, + interval: dateRange.effectiveComparisonInterval, + granularity: granularity + ) + }() let (current, previous) = try await (currentTask, previousTask) // Match current items with their previous counterparts let matchedItems = current.items.map { currentItem in - let previousItem = previous.items.first { $0.id == currentItem.id } + let previousItem = previous?.items.first { $0.id == currentItem.id } let itemID = TopListChartData.ItemID(type: selection.item, id: currentItem.id) return TopListChartData.Item(id: itemID, current: currentItem, previous: previousItem) } diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift index c860b8996ffe..84004d0666ff 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift @@ -92,7 +92,7 @@ private struct ArchiveSectionItemView: View { @Environment(\.router) private var router @Environment(\.context) private var context - + var body: some View { HStack(spacing: 0) { TopListArchiveSectionRowView( diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListMetricsView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListMetricsView.swift index 88e5ff81d4e7..2f0523193f38 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListMetricsView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListMetricsView.swift @@ -15,7 +15,7 @@ struct TopListMetricsView: View { .foregroundColor(.primary) .contentTransition(.numericText()) - if hasDetails { + if showChevron { Image(systemName: "chevron.forward") .font(.caption2.weight(.bold)) .foregroundStyle(Color(.tertiaryLabel)) From 1738de5af47c941c5b7bea9296e8a6466a5622e2 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 25 Jul 2025 08:38:17 -0400 Subject: [PATCH 065/349] Add navigationfor archive rows --- Modules/Sources/JetpackStats/StatsRouter.swift | 8 ++++++++ .../JetpackStats/Views/TopList/TopListItemView.swift | 6 ++++++ 2 files changed, 14 insertions(+) diff --git a/Modules/Sources/JetpackStats/StatsRouter.swift b/Modules/Sources/JetpackStats/StatsRouter.swift index 0ae41e651568..245ac52801d2 100644 --- a/Modules/Sources/JetpackStats/StatsRouter.swift +++ b/Modules/Sources/JetpackStats/StatsRouter.swift @@ -1,5 +1,6 @@ import SwiftUI import UIKit +import SafariServices @MainActor public protocol StatsRouterScreenFactory: AnyObject { @@ -39,6 +40,13 @@ public final class StatsRouter: @unchecked Sendable { let commentsVC = factory.makeCommentsListViewController(siteID: siteID, postID: postID) navigationController?.pushViewController(commentsVC, animated: true) } + + @MainActor + func openURL(_ url: URL) { + // Open URL in in-app Safari + let safariViewController = SFSafariViewController(url: url) + viewController?.present(safariViewController, animated: true) + } } class MockStatsRouterScreenFactory: StatsRouterScreenFactory { diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift index 41cbd8199b15..f918987a4671 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift @@ -83,6 +83,8 @@ private extension TopListItemView { switch currentItem { case is TopListData.Post: return true + case is TopListData.ArchiveItem: + return true default: return false } @@ -95,6 +97,10 @@ private extension TopListItemView { .environment(\.context, context) .environment(\.router, router) router.navigate(to: detailsView) + case let archiveItem as TopListData.ArchiveItem: + if let url = URL(string: archiveItem.href) { + router.openURL(url) + } default: break } From eb2f4aa9125aa9f5c77805cfac3cc998e73177c0 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 25 Jul 2025 08:48:06 -0400 Subject: [PATCH 066/349] Minor layout issues fixed --- .../JetpackStats/Cards/TopListCard.swift | 2 +- .../Rows/TopListArchiveSectionRowView.swift | 27 +++++++++---------- .../Views/TopList/TopListItemView.swift | 2 -- .../Views/TopList/TopListItemsView.swift | 2 +- 4 files changed, 15 insertions(+), 18 deletions(-) diff --git a/Modules/Sources/JetpackStats/Cards/TopListCard.swift b/Modules/Sources/JetpackStats/Cards/TopListCard.swift index 81a0f418fcfb..b81f93d43e80 100644 --- a/Modules/Sources/JetpackStats/Cards/TopListCard.swift +++ b/Modules/Sources/JetpackStats/Cards/TopListCard.swift @@ -155,7 +155,7 @@ struct TopListCard: View { .font(.callout) .foregroundColor(.primary) Image(systemName: "chevron.right") - .font(.caption) + .font(.caption.weight(.semibold)) .foregroundColor(.secondary) } .font(.body) diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListArchiveSectionRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListArchiveSectionRowView.swift index de8c02a4c161..6f2a96f91ca3 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListArchiveSectionRowView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListArchiveSectionRowView.swift @@ -6,21 +6,20 @@ struct TopListArchiveSectionRowView: View { var isExpanded: Bool = false var body: some View { - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 4) { - Image(systemName: isExpanded ? "chevron.down" : "chevron.forward") - .frame(width: 16) - .font(.caption) - .foregroundColor(.secondary) - .animation(.none, value: isExpanded) - - Text(localizedSectionName) - .font(.callout) - .foregroundColor(.primary) - .lineLimit(1) - } - .padding(.trailing, 4) + HStack(spacing: 0) { + Image(systemName: isExpanded ? "chevron.down" : "chevron.forward") + .frame(width: 12) // Centering + .font(.caption.weight(.semibold)) + .foregroundColor(.secondary) + .frame(width: Constants.step2, alignment: .leading) + .animation(.none, value: isExpanded) + + Text(localizedSectionName) + .font(.callout) + .foregroundColor(.primary) + .lineLimit(1) } + .padding(.trailing, 4) } private var localizedSectionName: String { diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift index f918987a4671..e8cc32e30a3c 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift @@ -46,8 +46,6 @@ struct TopListItemView: View { TopListVideoRowView(item: video, showDetails: showDetails) case let archiveItem as TopListData.ArchiveItem: TopListArchiveItemRowView(item: archiveItem, showDetails: showDetails) - case let archiveSection as TopListData.ArchiveSection: - TopListArchiveSectionRowView(item: archiveSection, showDetails: showDetails) default: let _ = assertionFailure("unsupported item: \(currentItem)") EmptyView() diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift index 84004d0666ff..0b5a348e5a07 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift @@ -43,7 +43,7 @@ struct TopListItemsView: View { showDetails: showDetails, dateRange: dateRange ) - .padding(.leading, Constants.step4) + .padding(.leading, Constants.step2) .transition(.asymmetric( insertion: .opacity.combined(with: .move(edge: .top)), removal: .opacity.combined(with: .move(edge: .top)) From 800d9569b58640a9e885891639e824ff1f56993a Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 25 Jul 2025 09:10:37 -0400 Subject: [PATCH 067/349] Refactor --- .../Services/Data/TopListData.swift | 19 ++++- ... => TopListExpandableSectionRowView.swift} | 18 +--- .../Views/TopList/TopListItemsView.swift | 84 ++++++++++--------- 3 files changed, 67 insertions(+), 54 deletions(-) rename Modules/Sources/JetpackStats/Views/TopList/Rows/{TopListArchiveSectionRowView.swift => TopListExpandableSectionRowView.swift} (55%) diff --git a/Modules/Sources/JetpackStats/Services/Data/TopListData.swift b/Modules/Sources/JetpackStats/Services/Data/TopListData.swift index 240a97044dfa..52804bff8441 100644 --- a/Modules/Sources/JetpackStats/Services/Data/TopListData.swift +++ b/Modules/Sources/JetpackStats/Services/Data/TopListData.swift @@ -9,6 +9,12 @@ protocol TopListItem: Codable, Sendable, Identifiable { var id: String { get } } +protocol TopListExpandableItem: TopListItem { + associatedtype ItemType: TopListItem + var items: [ItemType] { get } + var displayName: String { get } +} + extension TopListData { struct Post: Codable, TopListItem { let title: String @@ -89,11 +95,22 @@ extension TopListData { var id: String { href } } - struct ArchiveSection: Codable, TopListItem { + struct ArchiveSection: Codable, TopListExpandableItem { let sectionName: String var items: [ArchiveItem] var metrics: SiteMetricsSet var id: String { sectionName } + + var displayName: String { + switch sectionName.lowercased() { + case "author": + return Strings.ArchiveSections.author + case "other": + return Strings.ArchiveSections.other + default: + return sectionName.capitalized + } + } } } diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListArchiveSectionRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListExpandableSectionRowView.swift similarity index 55% rename from Modules/Sources/JetpackStats/Views/TopList/Rows/TopListArchiveSectionRowView.swift rename to Modules/Sources/JetpackStats/Views/TopList/Rows/TopListExpandableSectionRowView.swift index 6f2a96f91ca3..29325255ba06 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListArchiveSectionRowView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListExpandableSectionRowView.swift @@ -1,7 +1,7 @@ import SwiftUI -struct TopListArchiveSectionRowView: View { - let item: TopListData.ArchiveSection +struct TopListExpandableSectionRowView: View { + let item: any TopListExpandableItem let showDetails: Bool var isExpanded: Bool = false @@ -14,23 +14,11 @@ struct TopListArchiveSectionRowView: View { .frame(width: Constants.step2, alignment: .leading) .animation(.none, value: isExpanded) - Text(localizedSectionName) + Text(item.displayName) .font(.callout) .foregroundColor(.primary) .lineLimit(1) } .padding(.trailing, 4) } - - private var localizedSectionName: String { - switch item.sectionName.lowercased() { - case "author": - return Strings.ArchiveSections.author - case "other": - return Strings.ArchiveSections.other - default: - // Fallback to capitalized for any unknown sections - return item.sectionName.capitalized - } - } } diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift index 0b5a348e5a07..833dea7aecf5 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift @@ -1,5 +1,17 @@ import SwiftUI +// MARK: - View Extension for Conditional Modifiers +private extension View { + @ViewBuilder + func `if`(_ condition: Bool, transform: (Self) -> Content) -> some View { + if condition { + transform(self) + } else { + self + } + } +} + struct TopListItemsView: View { let data: TopListChartData let itemLimit: Int @@ -10,33 +22,30 @@ struct TopListItemsView: View { var body: some View { VStack(spacing: Constants.step1 / 2) { - ForEach(data.items.prefix(itemLimit)) { item in - if let archiveSection = item.current as? TopListData.ArchiveSection { - // Archive section with expandable items + ForEach(Array(data.items.prefix(itemLimit))) { item in + if let expandableItem = item.current as? any TopListExpandableItem { + // Expandable section with child items VStack(spacing: Constants.step1 / 2) { - // Section header Button { - withAnimation(.easeInOut(duration: 0.25)) { - toggleSection(archiveSection.id) - } + toggleSection(expandableItem.id) } label: { - ArchiveSectionItemView( - section: archiveSection, - previousItem: item.previous, + ExpandableItemView( + section: expandableItem, + previousItem: item.previous as? (any TopListExpandableItem), metric: data.metric, maxValue: data.maxValue, dateRange: dateRange, - isExpanded: expandedSections.contains(archiveSection.id) + isExpanded: expandedSections.contains(expandableItem.id) ) } - .buttonStyle(PlainButtonStyle()) - + .buttonStyle(.plain) + // Expandable items - if expandedSections.contains(archiveSection.id) { + if expandedSections.contains(expandableItem.id) { VStack(spacing: Constants.step1 / 2) { - ForEach(archiveSection.items) { archiveItem in + ForEach(Array(expandableItem.items), id: \.id) { childItem in TopListItemView( - currentItem: archiveItem, + currentItem: childItem, previousItem: nil, metric: data.metric, maxValue: data.maxValue, @@ -53,25 +62,28 @@ struct TopListItemsView: View { } } } else { - // Regular item - TopListItemView( - currentItem: item.current, - previousItem: item.previous, - metric: data.metric, - maxValue: data.maxValue, - showDetails: showDetails, - dateRange: dateRange - ) - .transition(.move(edge: .leading) + makeItemView(for: item) + .transition(.move(edge: .leading) .combined(with: .scale(scale: 0.75)) .combined(with: .opacity)) } } } .animation(.spring, value: ObjectIdentifier(data)) - .animation(.easeInOut(duration: 0.25), value: expandedSections) + .animation(.spring, value: expandedSections) } - + + private func makeItemView(for item: TopListChartData.Item) -> some View { + TopListItemView( + currentItem: item.current, + previousItem: item.previous, + metric: data.metric, + maxValue: data.maxValue, + showDetails: showDetails, + dateRange: dateRange + ) + } + private func toggleSection(_ sectionId: String) { if expandedSections.contains(sectionId) { expandedSections.remove(sectionId) @@ -81,22 +93,18 @@ struct TopListItemsView: View { } } -// Custom view for archive section header that can show expanded state -private struct ArchiveSectionItemView: View { - let section: TopListData.ArchiveSection - let previousItem: (any TopListItem)? +// Generic view for expandable section headers that can show expanded state +private struct ExpandableItemView: View { + let section: any TopListExpandableItem + let previousItem: (any TopListExpandableItem)? let metric: SiteMetric let maxValue: Int - let dateRange: StatsDateRange let isExpanded: Bool - - @Environment(\.router) private var router - @Environment(\.context) private var context var body: some View { HStack(spacing: 0) { - TopListArchiveSectionRowView( - item: section, + TopListExpandableSectionRowView( + item: section as any TopListExpandableItem, showDetails: false, isExpanded: isExpanded ) From 6f72a058f41a2efcb05b1e4ae141282a7d94ad4c Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 25 Jul 2025 09:19:01 -0400 Subject: [PATCH 068/349] Refator ToplitsChartData --- .../Cards/RealtimeTopListCard.swift | 10 ++--- .../Cards/TopListCardViewModel.swift | 19 ++++++--- .../Services/Data/TopListChartData.swift | 41 ++++++++----------- .../Services/Data/TopListData.swift | 9 ++-- .../Views/TopList/TopListItemsView.swift | 40 ++++++------------ 5 files changed, 49 insertions(+), 70 deletions(-) diff --git a/Modules/Sources/JetpackStats/Cards/RealtimeTopListCard.swift b/Modules/Sources/JetpackStats/Cards/RealtimeTopListCard.swift index 4350f1bab7d5..817c1cc88a2c 100644 --- a/Modules/Sources/JetpackStats/Cards/RealtimeTopListCard.swift +++ b/Modules/Sources/JetpackStats/Cards/RealtimeTopListCard.swift @@ -95,11 +95,9 @@ struct RealtimeTopListCard: View { let chartData = TopListChartData( item: selectedItem, metric: .views, - items: data.items.map { - let itemID = TopListChartData.ItemID(type: selectedItem, id: $0.id) - return TopListChartData.Item(id: itemID, current: $0, previous: nil) - }, - maxValue: viewModel.maxValue, + items: data.items, + previousItems: [:], // No previous data for realtime + maxValue: viewModel.maxValue ) return TopListItemsView( @@ -120,7 +118,7 @@ struct RealtimeTopListCard: View { metric: .views, itemCount: 6 ) - return TopListData(items: chartData.items.map { $0.current }) + return TopListData(items: chartData.items) } } diff --git a/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift b/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift index 27c16eebc347..04c7cb553e7a 100644 --- a/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift +++ b/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift @@ -145,11 +145,12 @@ final class TopListCardViewModel: ObservableObject, TrafficCardViewModel { let (current, previous) = try await (currentTask, previousTask) - // Match current items with their previous counterparts - let matchedItems = current.items.map { currentItem in - let previousItem = previous?.items.first { $0.id == currentItem.id } - let itemID = TopListChartData.ItemID(type: selection.item, id: currentItem.id) - return TopListChartData.Item(id: itemID, current: currentItem, previous: previousItem) + // Build previous items dictionary + var previousItemsDict: [String: any TopListItem] = [:] + if let previousItems = previous?.items { + for item in previousItems { + previousItemsDict[item.id] = item + } } // Calculate max value from current items based on selected metric @@ -158,6 +159,12 @@ final class TopListCardViewModel: ObservableObject, TrafficCardViewModel { .compactMap { $0.metrics[metric] } .max() ?? 1 - return TopListChartData(item: selection.item, metric: metric, items: matchedItems, maxValue: maxValue) + return TopListChartData( + item: selection.item, + metric: metric, + items: current.items, + previousItems: previousItemsDict, + maxValue: maxValue + ) } } diff --git a/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift b/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift index 8c742fd8b456..b496acd26969 100644 --- a/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift +++ b/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift @@ -1,24 +1,10 @@ import Foundation final class TopListChartData { - struct Item: Identifiable { - let id: ItemID - let current: any TopListItem - let previous: (any TopListItem)? - } - - /// - warning: It's required for animations in ``TopListItemsView`` to work - /// well for IDs to be unique across the domains. If we were just to use - /// `String`, there would be collisions across domains, e.g. post and author - /// using the same String ID "1". - struct ItemID: Hashable { - let type: TopListItemType - let id: String - } - let item: TopListItemType let metric: SiteMetric - let items: [Item] + let items: [any TopListItem] + let previousItems: [String: any TopListItem] let maxValue: Int struct ListID: Hashable { @@ -30,12 +16,17 @@ final class TopListChartData { ListID(item: item, metric: metric) } - init(item: TopListItemType, metric: SiteMetric, items: [Item], maxValue: Int) { + init(item: TopListItemType, metric: SiteMetric, items: [any TopListItem], previousItems: [String: any TopListItem] = [:], maxValue: Int) { self.item = item self.metric = metric self.items = items + self.previousItems = previousItems self.maxValue = maxValue } + + func previousItem(for currentItem: any TopListItem) -> (any TopListItem)? { + previousItems[currentItem.id] + } } // MARK: - Mock Data @@ -46,22 +37,24 @@ extension TopListChartData { metric: SiteMetric = .views, itemCount: Int = 6 ) -> TopListChartData { - let items = mockItems(for: itemType, metric: metric, count: itemCount) - let matchedItems = items.map { item in - // Create previous item with slightly different values + let currentItems = mockItems(for: itemType, metric: metric, count: itemCount) + + // Create previous items dictionary + var previousItemsDict: [String: any TopListItem] = [:] + for item in currentItems { let previousItem = mockPreviousItem(from: item, metric: metric) - let itemID = ItemID(type: itemType, id: item.id) - return Item(id: itemID, current: item, previous: previousItem) + previousItemsDict[item.id] = previousItem } - let maxValue = items + let maxValue = currentItems .compactMap { $0.metrics[metric] } .max() ?? 1 return TopListChartData( item: itemType, metric: metric, - items: matchedItems, + items: currentItems, + previousItems: previousItemsDict, maxValue: maxValue ) } diff --git a/Modules/Sources/JetpackStats/Services/Data/TopListData.swift b/Modules/Sources/JetpackStats/Services/Data/TopListData.swift index 52804bff8441..5b397498c00a 100644 --- a/Modules/Sources/JetpackStats/Services/Data/TopListData.swift +++ b/Modules/Sources/JetpackStats/Services/Data/TopListData.swift @@ -104,12 +104,9 @@ extension TopListData { var displayName: String { switch sectionName.lowercased() { - case "author": - return Strings.ArchiveSections.author - case "other": - return Strings.ArchiveSections.other - default: - return sectionName.capitalized + case "author": Strings.ArchiveSections.author + case "other": Strings.ArchiveSections.other + default: sectionName.capitalized } } } diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift index 833dea7aecf5..2d36e883d35d 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift @@ -1,17 +1,5 @@ import SwiftUI -// MARK: - View Extension for Conditional Modifiers -private extension View { - @ViewBuilder - func `if`(_ condition: Bool, transform: (Self) -> Content) -> some View { - if condition { - transform(self) - } else { - self - } - } -} - struct TopListItemsView: View { let data: TopListChartData let itemLimit: Int @@ -22,8 +10,8 @@ struct TopListItemsView: View { var body: some View { VStack(spacing: Constants.step1 / 2) { - ForEach(Array(data.items.prefix(itemLimit))) { item in - if let expandableItem = item.current as? any TopListExpandableItem { + ForEach(Array(data.items.prefix(itemLimit)), id: \.id) { item in + if let expandableItem = item as? any TopListExpandableItem { // Expandable section with child items VStack(spacing: Constants.step1 / 2) { Button { @@ -31,7 +19,7 @@ struct TopListItemsView: View { } label: { ExpandableItemView( section: expandableItem, - previousItem: item.previous as? (any TopListExpandableItem), + previousItem: data.previousItem(for: expandableItem) as? (any TopListExpandableItem), metric: data.metric, maxValue: data.maxValue, dateRange: dateRange, @@ -62,8 +50,15 @@ struct TopListItemsView: View { } } } else { - makeItemView(for: item) - .transition(.move(edge: .leading) + TopListItemView( + currentItem: item, + previousItem: data.previousItem(for: item), + metric: data.metric, + maxValue: data.maxValue, + showDetails: showDetails, + dateRange: dateRange + ) + .transition(.move(edge: .leading) .combined(with: .scale(scale: 0.75)) .combined(with: .opacity)) } @@ -73,17 +68,6 @@ struct TopListItemsView: View { .animation(.spring, value: expandedSections) } - private func makeItemView(for item: TopListChartData.Item) -> some View { - TopListItemView( - currentItem: item.current, - previousItem: item.previous, - metric: data.metric, - maxValue: data.maxValue, - showDetails: showDetails, - dateRange: dateRange - ) - } - private func toggleSection(_ sectionId: String) { if expandedSections.contains(sectionId) { expandedSections.remove(sectionId) From 2c4c746fc1d85e547f9ab415ad3b53ba020a24b7 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 25 Jul 2025 09:33:24 -0400 Subject: [PATCH 069/349] Refactor TopListItemID --- .../Cards/TopListCardViewModel.swift | 2 +- .../Services/Data/TopListChartData.swift | 6 +- .../Services/Data/TopListData.swift | 52 ++++++--- .../Services/Mocks/MockStatsService.swift | 2 +- .../Views/TopList/TopListItemsView.swift | 108 +++++++++--------- 5 files changed, 99 insertions(+), 71 deletions(-) diff --git a/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift b/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift index 04c7cb553e7a..9837c5e79b32 100644 --- a/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift +++ b/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift @@ -146,7 +146,7 @@ final class TopListCardViewModel: ObservableObject, TrafficCardViewModel { let (current, previous) = try await (currentTask, previousTask) // Build previous items dictionary - var previousItemsDict: [String: any TopListItem] = [:] + var previousItemsDict: [TopListItemID: any TopListItem] = [:] if let previousItems = previous?.items { for item in previousItems { previousItemsDict[item.id] = item diff --git a/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift b/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift index b496acd26969..ea1eab865dfb 100644 --- a/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift +++ b/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift @@ -4,7 +4,7 @@ final class TopListChartData { let item: TopListItemType let metric: SiteMetric let items: [any TopListItem] - let previousItems: [String: any TopListItem] + let previousItems: [TopListItemID: any TopListItem] let maxValue: Int struct ListID: Hashable { @@ -16,7 +16,7 @@ final class TopListChartData { ListID(item: item, metric: metric) } - init(item: TopListItemType, metric: SiteMetric, items: [any TopListItem], previousItems: [String: any TopListItem] = [:], maxValue: Int) { + init(item: TopListItemType, metric: SiteMetric, items: [any TopListItem], previousItems: [TopListItemID: any TopListItem] = [:], maxValue: Int) { self.item = item self.metric = metric self.items = items @@ -40,7 +40,7 @@ extension TopListChartData { let currentItems = mockItems(for: itemType, metric: metric, count: itemCount) // Create previous items dictionary - var previousItemsDict: [String: any TopListItem] = [:] + var previousItemsDict: [TopListItemID: any TopListItem] = [:] for item in currentItems { let previousItem = mockPreviousItem(from: item, metric: metric) previousItemsDict[item.id] = previousItem diff --git a/Modules/Sources/JetpackStats/Services/Data/TopListData.swift b/Modules/Sources/JetpackStats/Services/Data/TopListData.swift index 5b397498c00a..63b9bb2e78ad 100644 --- a/Modules/Sources/JetpackStats/Services/Data/TopListData.swift +++ b/Modules/Sources/JetpackStats/Services/Data/TopListData.swift @@ -4,14 +4,18 @@ struct TopListData: Sendable { let items: [any TopListItem] } +struct TopListItemID: Hashable { + let type: TopListItemType + let id: String +} + protocol TopListItem: Codable, Sendable, Identifiable { var metrics: SiteMetricsSet { get set } - var id: String { get } + var id: TopListItemID { get } } protocol TopListExpandableItem: TopListItem { - associatedtype ItemType: TopListItem - var items: [ItemType] { get } + var children: [any TopListItem] { get } var displayName: String { get } } @@ -25,7 +29,9 @@ extension TopListData { let author: String? var metrics: SiteMetricsSet - var id: String { postID ?? title } + var id: TopListItemID { + TopListItemID(type: .postsAndPages, id: postID ?? title) + } } struct Referrer: Codable, TopListItem { @@ -33,7 +39,9 @@ extension TopListData { let domain: String? var metrics: SiteMetricsSet - var id: String { domain ?? name } + var id: TopListItemID { + TopListItemID(type: .referrers, id: domain ?? name) + } } struct Location: Codable, TopListItem { @@ -42,7 +50,9 @@ extension TopListData { let countryCode: String? var metrics: SiteMetricsSet - var id: String { countryCode ?? country } + var id: TopListItemID { + TopListItemID(type: .locations, id: countryCode ?? country) + } } struct Author: Codable, TopListItem { @@ -52,7 +62,9 @@ extension TopListData { var metrics: SiteMetricsSet var avatarURL: URL? - var id: String { userId } + var id: TopListItemID { + TopListItemID(type: .authors, id: userId) + } } struct ExternalLink: Codable, TopListItem { @@ -60,7 +72,9 @@ extension TopListData { let title: String? var metrics: SiteMetricsSet - var id: String { url } + var id: TopListItemID { + TopListItemID(type: .externalLinks, id: url) + } } struct FileDownload: Codable, TopListItem { @@ -68,14 +82,18 @@ extension TopListData { let filePath: String? var metrics: SiteMetricsSet - var id: String { filePath ?? fileName } + var id: TopListItemID { + TopListItemID(type: .fileDownloads, id: filePath ?? fileName) + } } struct SearchTerm: Codable, TopListItem { let term: String var metrics: SiteMetricsSet - var id: String { term } + var id: TopListItemID { + TopListItemID(type: .searchTerms, id: term) + } } struct Video: Codable, TopListItem { @@ -84,7 +102,9 @@ extension TopListData { let videoUrl: URL? var metrics: SiteMetricsSet - var id: String { postId } + var id: TopListItemID { + TopListItemID(type: .videos, id: postId) + } } struct ArchiveItem: Codable, TopListItem { @@ -92,7 +112,9 @@ extension TopListData { let value: String var metrics: SiteMetricsSet - var id: String { href } + var id: TopListItemID { + TopListItemID(type: .archive, id: href) + } } struct ArchiveSection: Codable, TopListExpandableItem { @@ -100,7 +122,11 @@ extension TopListData { var items: [ArchiveItem] var metrics: SiteMetricsSet - var id: String { sectionName } + var children: [any TopListItem] { items } + + var id: TopListItemID { + TopListItemID(type: .archive, id: sectionName) + } var displayName: String { switch sectionName.lowercased() { diff --git a/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift b/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift index de11fb67125b..3ea59a3e37b4 100644 --- a/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift +++ b/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift @@ -82,7 +82,7 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { } // Aggregate all items across the date range - var aggregatedItems: [String: (any TopListItem, Int)] = [:] // Store item and aggregated metrics + var aggregatedItems: [TopListItemID: (any TopListItem, Int)] = [:] // Store item and aggregated metrics for (_, dailyItems) in filteredData { for item in dailyItems { diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift index 2d36e883d35d..f78eb4585e29 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift @@ -6,69 +6,71 @@ struct TopListItemsView: View { let dateRange: StatsDateRange var showDetails = true - @State private var expandedSections: Set = [] + @State private var expandedSections: Set = [] var body: some View { VStack(spacing: Constants.step1 / 2) { - ForEach(Array(data.items.prefix(itemLimit)), id: \.id) { item in - if let expandableItem = item as? any TopListExpandableItem { - // Expandable section with child items - VStack(spacing: Constants.step1 / 2) { - Button { - toggleSection(expandableItem.id) - } label: { - ExpandableItemView( - section: expandableItem, - previousItem: data.previousItem(for: expandableItem) as? (any TopListExpandableItem), - metric: data.metric, - maxValue: data.maxValue, - dateRange: dateRange, - isExpanded: expandedSections.contains(expandableItem.id) - ) - } - .buttonStyle(.plain) - - // Expandable items - if expandedSections.contains(expandableItem.id) { - VStack(spacing: Constants.step1 / 2) { - ForEach(Array(expandableItem.items), id: \.id) { childItem in - TopListItemView( - currentItem: childItem, - previousItem: nil, - metric: data.metric, - maxValue: data.maxValue, - showDetails: showDetails, - dateRange: dateRange - ) - .padding(.leading, Constants.step2) - .transition(.asymmetric( - insertion: .opacity.combined(with: .move(edge: .top)), - removal: .opacity.combined(with: .move(edge: .top)) - )) - } - } - } - } + ForEach(data.items.prefix(itemLimit), id: \.id) { item in + if let item = item as? any TopListExpandableItem { + makeExpandableSection(with: item) } else { - TopListItemView( - currentItem: item, - previousItem: data.previousItem(for: item), - metric: data.metric, - maxValue: data.maxValue, - showDetails: showDetails, - dateRange: dateRange - ) - .transition(.move(edge: .leading) - .combined(with: .scale(scale: 0.75)) - .combined(with: .opacity)) + makeView(for: item) + .transition(.move(edge: .leading) + .combined(with: .scale(scale: 0.75)) + .combined(with: .opacity)) } } } .animation(.spring, value: ObjectIdentifier(data)) - .animation(.spring, value: expandedSections) } - private func toggleSection(_ sectionId: String) { + private func makeExpandableSection(with item: any TopListExpandableItem) -> some View { + VStack(spacing: Constants.step1 / 2) { + Button { + withAnimation(.spring) { + toggleSection(item.id) + } + } label: { + ExpandableItemView( + section: item, + previousItem: data.previousItem(for: item) as? (any TopListExpandableItem), + metric: data.metric, + maxValue: data.maxValue, + isExpanded: expandedSections.contains(item.id) + ) + } + .buttonStyle(.plain) + + if expandedSections.contains(item.id) { + VStack(spacing: Constants.step1 / 2) { + ForEach(Array(item.children), id: \.id) { child in + makeView(for: child) + .padding(.leading, Constants.step2) + .transition(.move(edge: .leading) + .combined(with: .scale(scale: 0.75)) + .combined(with: .opacity)) +// .transition(.asymmetric( +// insertion: .opacity.combined(with: .move(edge: .top)), +// removal: .opacity.combined(with: .move(edge: .top)) +// )) + } + } + } + } + } + + private func makeView(for item: any TopListItem) -> some View { + TopListItemView( + currentItem: item, + previousItem: data.previousItem(for: item), + metric: data.metric, + maxValue: data.maxValue, + showDetails: showDetails, + dateRange: dateRange + ) + } + + private func toggleSection(_ sectionId: TopListItemID) { if expandedSections.contains(sectionId) { expandedSections.remove(sectionId) } else { From 411abf0ef8003fd392ee07ee56a2f71d2bca9ad6 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 25 Jul 2025 09:33:51 -0400 Subject: [PATCH 070/349] SwiftLint --- .../Cards/RealtimeTopListCard.swift | 21 +++++++++---------- .../Cards/TopListCardViewModel.swift | 2 +- .../Services/Data/TopListChartData.swift | 14 ++++++------- .../Services/Data/TopListData.swift | 20 +++++++++--------- .../Services/Mocks/MockStatsService.swift | 6 +++--- .../JetpackStats/Services/StatsService.swift | 14 ++++++------- .../Sources/JetpackStats/StatsRouter.swift | 2 +- Modules/Sources/JetpackStats/Strings.swift | 2 +- .../Rows/TopListArchiveItemRowView.swift | 4 ++-- .../TopListExpandableSectionRowView.swift | 2 +- .../Views/TopList/TopListItemsView.swift | 6 +++--- 11 files changed, 46 insertions(+), 47 deletions(-) diff --git a/Modules/Sources/JetpackStats/Cards/RealtimeTopListCard.swift b/Modules/Sources/JetpackStats/Cards/RealtimeTopListCard.swift index 817c1cc88a2c..1edc401be15d 100644 --- a/Modules/Sources/JetpackStats/Cards/RealtimeTopListCard.swift +++ b/Modules/Sources/JetpackStats/Cards/RealtimeTopListCard.swift @@ -7,23 +7,23 @@ struct RealtimeTopListCard: View { @State private var selectedItem: TopListItemType @Environment(\.context) var context - + init( availableDataTypes: [TopListItemType] = TopListItemType.allCases, initialDataType: TopListItemType = .postsAndPages, service: any StatsServiceProtocol ) { self.availableItems = availableDataTypes - + let selectedItem = availableDataTypes.contains(initialDataType) ? initialDataType : availableDataTypes.first ?? .postsAndPages self._selectedItem = State(initialValue: selectedItem) - + let viewModel = RealtimeTopListCardViewModel(service: service) self._viewModel = StateObject(wrappedValue: viewModel) - + viewModel.loadData(for: selectedItem) } - + var body: some View { VStack(alignment: .leading, spacing: 16) { HStack { @@ -43,7 +43,6 @@ struct RealtimeTopListCard: View { viewModel.loadData(for: newValue) } } - private var headerView: some View { HStack { @@ -99,7 +98,7 @@ struct RealtimeTopListCard: View { previousItems: [:], // No previous data for realtime maxValue: viewModel.maxValue ) - + return TopListItemsView( data: chartData, itemLimit: 6, @@ -111,7 +110,7 @@ struct RealtimeTopListCard: View { private var loadingView: some View { topListItemsView(data: mockData) } - + private var mockData: TopListData { let chartData = TopListChartData.mock( for: selectedItem, @@ -120,7 +119,7 @@ struct RealtimeTopListCard: View { ) return TopListData(items: chartData.items) } - + } // MARK: - Preview @@ -137,7 +136,7 @@ struct RealtimeTopListCard: View { .padding() .background(Color(.systemBackground)) .cornerRadius(12) - + // Referrers RealtimeTopListCard( availableDataTypes: [.referrers], @@ -147,7 +146,7 @@ struct RealtimeTopListCard: View { .padding() .background(Color(.systemBackground)) .cornerRadius(12) - + // Locations RealtimeTopListCard( availableDataTypes: [.locations], diff --git a/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift b/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift index 9837c5e79b32..2b748695f41b 100644 --- a/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift +++ b/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift @@ -131,7 +131,7 @@ final class TopListCardViewModel: ObservableObject, TrafficCardViewModel { interval: dateRange.dateInterval, granularity: granularity ) - + // Fetch previous data only for items that support it async let previousTask: TopListData? = { guard selection.item != .archive else { return nil } diff --git a/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift b/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift index ea1eab865dfb..66346249f9cb 100644 --- a/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift +++ b/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift @@ -23,7 +23,7 @@ final class TopListChartData { self.previousItems = previousItems self.maxValue = maxValue } - + func previousItem(for currentItem: any TopListItem) -> (any TopListItem)? { previousItems[currentItem.id] } @@ -38,7 +38,7 @@ extension TopListChartData { itemCount: Int = 6 ) -> TopListChartData { let currentItems = mockItems(for: itemType, metric: metric, count: itemCount) - + // Create previous items dictionary var previousItemsDict: [TopListItemID: any TopListItem] = [:] for item in currentItems { @@ -276,7 +276,7 @@ extension TopListChartData { ) } } - + private static func mockArchive(metric: SiteMetric, count: Int) -> [any TopListItem] { // Create mock archive sections let archiveSections = [ @@ -306,7 +306,7 @@ extension TopListChartData { ("/2023/10/", 900) ]) ] - + return archiveSections.prefix(count).map { sectionData in let sectionName = sectionData.0 let items = sectionData.1.map { itemData in @@ -317,10 +317,10 @@ extension TopListChartData { metrics: metrics ) } - + // Calculate total views for the section let totalViews = items.reduce(0) { $0 + ($1.metrics[metric] ?? 0) } - + return TopListData.ArchiveSection( sectionName: sectionName, items: items, @@ -371,7 +371,7 @@ extension TopListChartData { let trendFactor = Double.random(in: 0.7...1.3) let currentValue = item.metrics[metric] ?? 0 item.metrics[metric] = Int(Double(currentValue) * trendFactor) - + // Special handling for archive sections - update child items too if var archiveSection = item as? TopListData.ArchiveSection { archiveSection.items = archiveSection.items.map { archiveItem in diff --git a/Modules/Sources/JetpackStats/Services/Data/TopListData.swift b/Modules/Sources/JetpackStats/Services/Data/TopListData.swift index 63b9bb2e78ad..24e3d284632d 100644 --- a/Modules/Sources/JetpackStats/Services/Data/TopListData.swift +++ b/Modules/Sources/JetpackStats/Services/Data/TopListData.swift @@ -29,7 +29,7 @@ extension TopListData { let author: String? var metrics: SiteMetricsSet - var id: TopListItemID { + var id: TopListItemID { TopListItemID(type: .postsAndPages, id: postID ?? title) } } @@ -39,7 +39,7 @@ extension TopListData { let domain: String? var metrics: SiteMetricsSet - var id: TopListItemID { + var id: TopListItemID { TopListItemID(type: .referrers, id: domain ?? name) } } @@ -50,7 +50,7 @@ extension TopListData { let countryCode: String? var metrics: SiteMetricsSet - var id: TopListItemID { + var id: TopListItemID { TopListItemID(type: .locations, id: countryCode ?? country) } } @@ -62,7 +62,7 @@ extension TopListData { var metrics: SiteMetricsSet var avatarURL: URL? - var id: TopListItemID { + var id: TopListItemID { TopListItemID(type: .authors, id: userId) } } @@ -72,7 +72,7 @@ extension TopListData { let title: String? var metrics: SiteMetricsSet - var id: TopListItemID { + var id: TopListItemID { TopListItemID(type: .externalLinks, id: url) } } @@ -82,7 +82,7 @@ extension TopListData { let filePath: String? var metrics: SiteMetricsSet - var id: TopListItemID { + var id: TopListItemID { TopListItemID(type: .fileDownloads, id: filePath ?? fileName) } } @@ -91,7 +91,7 @@ extension TopListData { let term: String var metrics: SiteMetricsSet - var id: TopListItemID { + var id: TopListItemID { TopListItemID(type: .searchTerms, id: term) } } @@ -102,7 +102,7 @@ extension TopListData { let videoUrl: URL? var metrics: SiteMetricsSet - var id: TopListItemID { + var id: TopListItemID { TopListItemID(type: .videos, id: postId) } } @@ -112,7 +112,7 @@ extension TopListData { let value: String var metrics: SiteMetricsSet - var id: TopListItemID { + var id: TopListItemID { TopListItemID(type: .archive, id: href) } } @@ -124,7 +124,7 @@ extension TopListData { var children: [any TopListItem] { items } - var id: TopListItemID { + var id: TopListItemID { TopListItemID(type: .archive, id: sectionName) } diff --git a/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift b/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift index 3ea59a3e37b4..4d74e1fa455c 100644 --- a/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift +++ b/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift @@ -547,7 +547,7 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { dailyTopListData[dataType] = typeData } } - + /// Generates mock archive data with expandable sections private func generateMockArchiveData() -> [any TopListItem] { // Create mock archive sections based on the example JSON structure @@ -562,7 +562,7 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { ], metrics: SiteMetricsSet(views: 25) // Total views for the section ) - + let authorSection = TopListData.ArchiveSection( sectionName: "author", items: [ @@ -572,7 +572,7 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { ], metrics: SiteMetricsSet(views: 40) // Total views for the section ) - + return [otherSection, authorSection] } } diff --git a/Modules/Sources/JetpackStats/Services/StatsService.swift b/Modules/Sources/JetpackStats/Services/StatsService.swift index 1e40712c9c25..c2c81bfcfc65 100644 --- a/Modules/Sources/JetpackStats/Services/StatsService.swift +++ b/Modules/Sources/JetpackStats/Services/StatsService.swift @@ -181,7 +181,7 @@ actor StatsService: StatsServiceProtocol { default: throw StatsServiceError.unavailable } - + case .archive: switch metric { case .views: @@ -432,12 +432,12 @@ actor StatsService: StatsServiceProtocol { } 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( @@ -446,20 +446,20 @@ actor StatsService: StatsServiceProtocol { 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) } } diff --git a/Modules/Sources/JetpackStats/StatsRouter.swift b/Modules/Sources/JetpackStats/StatsRouter.swift index 245ac52801d2..d9a50dba1690 100644 --- a/Modules/Sources/JetpackStats/StatsRouter.swift +++ b/Modules/Sources/JetpackStats/StatsRouter.swift @@ -40,7 +40,7 @@ public final class StatsRouter: @unchecked Sendable { let commentsVC = factory.makeCommentsListViewController(siteID: siteID, postID: postID) navigationController?.pushViewController(commentsVC, animated: true) } - + @MainActor func openURL(_ url: URL) { // Open URL in in-app Safari diff --git a/Modules/Sources/JetpackStats/Strings.swift b/Modules/Sources/JetpackStats/Strings.swift index 7a8a4501b9e1..7fbb3a99c9ed 100644 --- a/Modules/Sources/JetpackStats/Strings.swift +++ b/Modules/Sources/JetpackStats/Strings.swift @@ -120,7 +120,7 @@ enum Strings { static let author = AppLocalizedString("jetpackStats.archiveSections.author", value: "Authors", comment: "Archive section for authors") static let other = AppLocalizedString("jetpackStats.archiveSections.other", value: "Other", comment: "Archive section for other items") } - + enum PostDetails { static let title = AppLocalizedString("jetpackStats.postDetails.title", value: "Post Stats", comment: "Navigation title") static func published(_ date: String) -> String { diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListArchiveItemRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListArchiveItemRowView.swift index b13d755cee92..5bf9987f71bc 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListArchiveItemRowView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListArchiveItemRowView.swift @@ -3,7 +3,7 @@ import SwiftUI struct TopListArchiveItemRowView: View { let item: TopListData.ArchiveItem let showDetails: Bool - + var body: some View { VStack(alignment: .leading, spacing: 2) { ZStack(alignment: .leading) { @@ -19,4 +19,4 @@ struct TopListArchiveItemRowView: View { .padding(.trailing, 4) } } -} \ No newline at end of file +} diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListExpandableSectionRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListExpandableSectionRowView.swift index 29325255ba06..185820426258 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListExpandableSectionRowView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListExpandableSectionRowView.swift @@ -4,7 +4,7 @@ struct TopListExpandableSectionRowView: View { let item: any TopListExpandableItem let showDetails: Bool var isExpanded: Bool = false - + var body: some View { HStack(spacing: 0) { Image(systemName: isExpanded ? "chevron.down" : "chevron.forward") diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift index f78eb4585e29..a454bf0e47a0 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift @@ -5,7 +5,7 @@ struct TopListItemsView: View { let itemLimit: Int let dateRange: StatsDateRange var showDetails = true - + @State private var expandedSections: Set = [] var body: some View { @@ -94,9 +94,9 @@ private struct ExpandableItemView: View { showDetails: false, isExpanded: isExpanded ) - + Spacer(minLength: 4) - + TopListMetricsView( currentValue: section.metrics[metric] ?? 0, previousValue: previousItem?.metrics[metric], From b0f0356c1465d9ab6c43f8b9056aadecbe3f5941 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 25 Jul 2025 09:37:20 -0400 Subject: [PATCH 071/349] Restore ChartCardViewModel --- .../Sources/JetpackStats/Screens/TrafficTabView.swift | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/Modules/Sources/JetpackStats/Screens/TrafficTabView.swift b/Modules/Sources/JetpackStats/Screens/TrafficTabView.swift index 1317fa27df80..e975a0889dc5 100644 --- a/Modules/Sources/JetpackStats/Screens/TrafficTabView.swift +++ b/Modules/Sources/JetpackStats/Screens/TrafficTabView.swift @@ -57,17 +57,16 @@ struct TrafficTabView: View { } } - #warning("TEMP") private func configureViewModels() { guard viewModels.isEmpty else { return } viewModels = [ -// ChartCardViewModel( -// metrics: context.service.supportedMetrics, -// dateRange: dateRange, -// service: context.service -// ), + ChartCardViewModel( + metrics: context.service.supportedMetrics, + dateRange: dateRange, + service: context.service + ), TopListCardViewModel( selection: .init(item: .postsAndPages, metric: .views), dateRange: dateRange, From 915706cd98a5594c34514192ec0208e8cc667863 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 25 Jul 2025 09:44:12 -0400 Subject: [PATCH 072/349] Rework mock data --- .../HistoricalData/historical-archive.json | 74 +++++++++++++++++++ .../historical-external-links.json | 50 +++++++++++++ .../historical-file-downloads.json | 44 +++++++++++ .../historical-search-terms.json | 44 +++++++++++ .../HistoricalData/historical-videos.json | 56 ++++++++++++++ .../Mocks/RealtimeData/realtime-archive.json | 74 +++++++++++++++++++ .../RealtimeData/realtime-external-links.json | 50 +++++++++++++ .../RealtimeData/realtime-file-downloads.json | 44 +++++++++++ .../RealtimeData/realtime-search-terms.json | 44 +++++++++++ .../Mocks/RealtimeData/realtime-videos.json | 56 ++++++++++++++ .../Services/Data/TopListData.swift | 4 + .../Services/Mocks/MockStatsService.swift | 39 ++-------- .../Views/TopList/TopListItemsView.swift | 4 - 13 files changed, 546 insertions(+), 37 deletions(-) create mode 100644 Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-archive.json create mode 100644 Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-external-links.json create mode 100644 Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-file-downloads.json create mode 100644 Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-search-terms.json create mode 100644 Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-videos.json create mode 100644 Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-archive.json create mode 100644 Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-external-links.json create mode 100644 Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-file-downloads.json create mode 100644 Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-search-terms.json create mode 100644 Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-videos.json diff --git a/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-archive.json b/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-archive.json new file mode 100644 index 000000000000..bf0d1ea84b11 --- /dev/null +++ b/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-archive.json @@ -0,0 +1,74 @@ +[ + { + "sectionName": "other", + "items": [ + { + "href": "http://example.com/wp-admin/admin.php?page=stats", + "value": "/wp-admin/admin.php?page=stats", + "metrics": { + "views": 10 + } + }, + { + "href": "http://example.com/wp-admin/", + "value": "/wp-admin/", + "metrics": { + "views": 4 + } + }, + { + "href": "http://example.com/wp-admin/edit.php", + "value": "/wp-admin/edit.php", + "metrics": { + "views": 4 + } + }, + { + "href": "http://example.com/wp-admin/index.php", + "value": "/wp-admin/index.php", + "metrics": { + "views": 2 + } + }, + { + "href": "http://example.com/wp-admin/revision.php?revision=12345", + "value": "/wp-admin/revision.php?revision=12345", + "metrics": { + "views": 2 + } + } + ], + "metrics": { + "views": 25 + } + }, + { + "sectionName": "author", + "items": [ + { + "href": "http://example.com/author/johndoe/", + "value": "johndoe", + "metrics": { + "views": 31 + } + }, + { + "href": "http://example.com/author/janedoe/", + "value": "janedoe", + "metrics": { + "views": 5 + } + }, + { + "href": "http://example.com/author/testuser/", + "value": "testuser", + "metrics": { + "views": 2 + } + } + ], + "metrics": { + "views": 40 + } + } +] \ No newline at end of file diff --git a/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-external-links.json b/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-external-links.json new file mode 100644 index 000000000000..596db538caba --- /dev/null +++ b/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-external-links.json @@ -0,0 +1,50 @@ +[ + { + "url": "https://developer.apple.com", + "title": "Apple Developer", + "metrics": { + "views": 1800, + "visitors": 1200 + } + }, + { + "url": "https://swift.org", + "title": "Swift.org", + "metrics": { + "views": 1500, + "visitors": 1000 + } + }, + { + "url": "https://github.com", + "title": "GitHub", + "metrics": { + "views": 1200, + "visitors": 800 + } + }, + { + "url": "https://stackoverflow.com", + "title": "Stack Overflow", + "metrics": { + "views": 1000, + "visitors": 700 + } + }, + { + "url": "https://raywenderlich.com", + "title": "Ray Wenderlich", + "metrics": { + "views": 800, + "visitors": 500 + } + }, + { + "url": "https://nshipster.com", + "title": "NSHipster", + "metrics": { + "views": 600, + "visitors": 400 + } + } +] \ No newline at end of file diff --git a/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-file-downloads.json b/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-file-downloads.json new file mode 100644 index 000000000000..32698b3b42c4 --- /dev/null +++ b/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-file-downloads.json @@ -0,0 +1,44 @@ +[ + { + "fileName": "annual-report-2024.pdf", + "filePath": "/downloads/reports/annual-report-2024.pdf", + "metrics": { + "downloads": 2500 + } + }, + { + "fileName": "swift-cheatsheet.pdf", + "filePath": "/downloads/docs/swift-cheatsheet.pdf", + "metrics": { + "downloads": 2100 + } + }, + { + "fileName": "app-screenshots.zip", + "filePath": "/downloads/media/app-screenshots.zip", + "metrics": { + "downloads": 1800 + } + }, + { + "fileName": "tutorial-video.mp4", + "filePath": "/downloads/videos/tutorial-video.mp4", + "metrics": { + "downloads": 1500 + } + }, + { + "fileName": "code-samples.zip", + "filePath": "/downloads/code/code-samples.zip", + "metrics": { + "downloads": 1200 + } + }, + { + "fileName": "whitepaper.pdf", + "filePath": "/downloads/docs/whitepaper.pdf", + "metrics": { + "downloads": 900 + } + } +] \ No newline at end of file diff --git a/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-search-terms.json b/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-search-terms.json new file mode 100644 index 000000000000..35c0ca273333 --- /dev/null +++ b/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-search-terms.json @@ -0,0 +1,44 @@ +[ + { + "term": "swiftui tutorial", + "metrics": { + "views": 3200, + "visitors": 2500 + } + }, + { + "term": "ios development guide", + "metrics": { + "views": 2800, + "visitors": 2200 + } + }, + { + "term": "swift async await", + "metrics": { + "views": 2400, + "visitors": 1900 + } + }, + { + "term": "xcode tips", + "metrics": { + "views": 2000, + "visitors": 1600 + } + }, + { + "term": "swift performance", + "metrics": { + "views": 1600, + "visitors": 1300 + } + }, + { + "term": "ios app architecture", + "metrics": { + "views": 1200, + "visitors": 1000 + } + } +] \ No newline at end of file diff --git a/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-videos.json b/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-videos.json new file mode 100644 index 000000000000..d38e6b1b395f --- /dev/null +++ b/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-videos.json @@ -0,0 +1,56 @@ +[ + { + "title": "Getting Started with SwiftUI", + "postId": "101", + "videoUrl": "https://example.com/videos/swiftui-intro.mp4", + "metrics": { + "views": 4500, + "likes": 250 + } + }, + { + "title": "iOS Development Best Practices", + "postId": "102", + "videoUrl": "https://example.com/videos/best-practices.mp4", + "metrics": { + "views": 3800, + "likes": 210 + } + }, + { + "title": "Advanced Swift Techniques", + "postId": "103", + "videoUrl": "https://example.com/videos/advanced-swift.mp4", + "metrics": { + "views": 3200, + "likes": 180 + } + }, + { + "title": "Building Custom Views", + "postId": "104", + "videoUrl": "https://example.com/videos/custom-views.mp4", + "metrics": { + "views": 2600, + "likes": 140 + } + }, + { + "title": "App Performance Optimization", + "postId": "105", + "videoUrl": "https://example.com/videos/performance.mp4", + "metrics": { + "views": 2000, + "likes": 110 + } + }, + { + "title": "Debugging Like a Pro", + "postId": "106", + "videoUrl": "https://example.com/videos/debugging.mp4", + "metrics": { + "views": 1500, + "likes": 90 + } + } +] \ No newline at end of file diff --git a/Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-archive.json b/Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-archive.json new file mode 100644 index 000000000000..bf0d1ea84b11 --- /dev/null +++ b/Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-archive.json @@ -0,0 +1,74 @@ +[ + { + "sectionName": "other", + "items": [ + { + "href": "http://example.com/wp-admin/admin.php?page=stats", + "value": "/wp-admin/admin.php?page=stats", + "metrics": { + "views": 10 + } + }, + { + "href": "http://example.com/wp-admin/", + "value": "/wp-admin/", + "metrics": { + "views": 4 + } + }, + { + "href": "http://example.com/wp-admin/edit.php", + "value": "/wp-admin/edit.php", + "metrics": { + "views": 4 + } + }, + { + "href": "http://example.com/wp-admin/index.php", + "value": "/wp-admin/index.php", + "metrics": { + "views": 2 + } + }, + { + "href": "http://example.com/wp-admin/revision.php?revision=12345", + "value": "/wp-admin/revision.php?revision=12345", + "metrics": { + "views": 2 + } + } + ], + "metrics": { + "views": 25 + } + }, + { + "sectionName": "author", + "items": [ + { + "href": "http://example.com/author/johndoe/", + "value": "johndoe", + "metrics": { + "views": 31 + } + }, + { + "href": "http://example.com/author/janedoe/", + "value": "janedoe", + "metrics": { + "views": 5 + } + }, + { + "href": "http://example.com/author/testuser/", + "value": "testuser", + "metrics": { + "views": 2 + } + } + ], + "metrics": { + "views": 40 + } + } +] \ No newline at end of file diff --git a/Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-external-links.json b/Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-external-links.json new file mode 100644 index 000000000000..34ccbb750cdd --- /dev/null +++ b/Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-external-links.json @@ -0,0 +1,50 @@ +[ + { + "url": "https://developer.apple.com", + "title": "Apple Developer", + "metrics": { + "views": 180, + "visitors": 120 + } + }, + { + "url": "https://swift.org", + "title": "Swift.org", + "metrics": { + "views": 150, + "visitors": 100 + } + }, + { + "url": "https://github.com", + "title": "GitHub", + "metrics": { + "views": 120, + "visitors": 80 + } + }, + { + "url": "https://stackoverflow.com", + "title": "Stack Overflow", + "metrics": { + "views": 100, + "visitors": 70 + } + }, + { + "url": "https://raywenderlich.com", + "title": "Ray Wenderlich", + "metrics": { + "views": 80, + "visitors": 50 + } + }, + { + "url": "https://nshipster.com", + "title": "NSHipster", + "metrics": { + "views": 60, + "visitors": 40 + } + } +] \ No newline at end of file diff --git a/Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-file-downloads.json b/Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-file-downloads.json new file mode 100644 index 000000000000..0751839627d6 --- /dev/null +++ b/Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-file-downloads.json @@ -0,0 +1,44 @@ +[ + { + "fileName": "annual-report-2024.pdf", + "filePath": "/downloads/reports/annual-report-2024.pdf", + "metrics": { + "downloads": 25 + } + }, + { + "fileName": "swift-cheatsheet.pdf", + "filePath": "/downloads/docs/swift-cheatsheet.pdf", + "metrics": { + "downloads": 21 + } + }, + { + "fileName": "app-screenshots.zip", + "filePath": "/downloads/media/app-screenshots.zip", + "metrics": { + "downloads": 18 + } + }, + { + "fileName": "tutorial-video.mp4", + "filePath": "/downloads/videos/tutorial-video.mp4", + "metrics": { + "downloads": 15 + } + }, + { + "fileName": "code-samples.zip", + "filePath": "/downloads/code/code-samples.zip", + "metrics": { + "downloads": 12 + } + }, + { + "fileName": "whitepaper.pdf", + "filePath": "/downloads/docs/whitepaper.pdf", + "metrics": { + "downloads": 9 + } + } +] \ No newline at end of file diff --git a/Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-search-terms.json b/Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-search-terms.json new file mode 100644 index 000000000000..1606cc301315 --- /dev/null +++ b/Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-search-terms.json @@ -0,0 +1,44 @@ +[ + { + "term": "swiftui tutorial", + "metrics": { + "views": 32, + "visitors": 25 + } + }, + { + "term": "ios development guide", + "metrics": { + "views": 28, + "visitors": 22 + } + }, + { + "term": "swift async await", + "metrics": { + "views": 24, + "visitors": 19 + } + }, + { + "term": "xcode tips", + "metrics": { + "views": 20, + "visitors": 16 + } + }, + { + "term": "swift performance", + "metrics": { + "views": 16, + "visitors": 13 + } + }, + { + "term": "ios app architecture", + "metrics": { + "views": 12, + "visitors": 10 + } + } +] \ No newline at end of file diff --git a/Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-videos.json b/Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-videos.json new file mode 100644 index 000000000000..29f2c66cee36 --- /dev/null +++ b/Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-videos.json @@ -0,0 +1,56 @@ +[ + { + "title": "Getting Started with SwiftUI", + "postId": "101", + "videoUrl": "https://example.com/videos/swiftui-intro.mp4", + "metrics": { + "views": 45, + "likes": 5 + } + }, + { + "title": "iOS Development Best Practices", + "postId": "102", + "videoUrl": "https://example.com/videos/best-practices.mp4", + "metrics": { + "views": 38, + "likes": 4 + } + }, + { + "title": "Advanced Swift Techniques", + "postId": "103", + "videoUrl": "https://example.com/videos/advanced-swift.mp4", + "metrics": { + "views": 32, + "likes": 3 + } + }, + { + "title": "Building Custom Views", + "postId": "104", + "videoUrl": "https://example.com/videos/custom-views.mp4", + "metrics": { + "views": 26, + "likes": 2 + } + }, + { + "title": "App Performance Optimization", + "postId": "105", + "videoUrl": "https://example.com/videos/performance.mp4", + "metrics": { + "views": 20, + "likes": 2 + } + }, + { + "title": "Debugging Like a Pro", + "postId": "106", + "videoUrl": "https://example.com/videos/debugging.mp4", + "metrics": { + "views": 15, + "likes": 1 + } + } +] \ No newline at end of file diff --git a/Modules/Sources/JetpackStats/Services/Data/TopListData.swift b/Modules/Sources/JetpackStats/Services/Data/TopListData.swift index 24e3d284632d..947191e6be56 100644 --- a/Modules/Sources/JetpackStats/Services/Data/TopListData.swift +++ b/Modules/Sources/JetpackStats/Services/Data/TopListData.swift @@ -4,6 +4,10 @@ struct TopListData: Sendable { let items: [any TopListItem] } +/// - warning: It's required for animations in ``TopListItemsView`` to work +/// well for IDs to be unique across the domains. If we were just to use +/// `String`, there would be collisions across domains, e.g. post and author +/// using the same String ID "1". struct TopListItemID: Hashable { let type: TopListItemType let id: String diff --git a/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift b/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift index 4d74e1fa455c..81c644453cad 100644 --- a/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift +++ b/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift @@ -186,7 +186,7 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { case .postsAndPages: fileName = "postsAndPages" case .archive: - return generateMockArchiveData() + fileName = "archive" case .referrers: fileName = "referrers" case .locations: @@ -246,8 +246,8 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { let posts = try decoder.decode([TopListData.Post].self, from: data) return posts case .archive: - // This case is handled by generateMockArchiveData - return [] + let sections = try decoder.decode([TopListData.ArchiveSection].self, from: data) + return sections } } catch { print("Failed to load \(fileName).json: \(error)") @@ -310,7 +310,7 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { case .postsAndPages: fileName = "historical-postsAndPages" case .archive: - return generateMockArchiveData() + fileName = "historical-archive" case .referrers: fileName = "historical-referrers" case .locations: @@ -370,8 +370,8 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { let posts = try decoder.decode([TopListData.Post].self, from: data) return posts case .archive: - // This case is handled by generateMockArchiveData - return [] + let sections = try decoder.decode([TopListData.ArchiveSection].self, from: data) + return sections } } catch { print("Failed to load \(fileName).json: \(error)") @@ -548,31 +548,4 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { } } - /// Generates mock archive data with expandable sections - private func generateMockArchiveData() -> [any TopListItem] { - // Create mock archive sections based on the example JSON structure - let otherSection = TopListData.ArchiveSection( - sectionName: "other", - items: [ - TopListData.ArchiveItem(href: "http://example.com/wp-admin/admin.php?page=stats", value: "/wp-admin/admin.php?page=stats", metrics: SiteMetricsSet(views: 10)), - TopListData.ArchiveItem(href: "http://example.com/wp-admin/", value: "/wp-admin/", metrics: SiteMetricsSet(views: 4)), - TopListData.ArchiveItem(href: "http://example.com/wp-admin/edit.php", value: "/wp-admin/edit.php", metrics: SiteMetricsSet(views: 4)), - TopListData.ArchiveItem(href: "http://example.com/wp-admin/index.php", value: "/wp-admin/index.php", metrics: SiteMetricsSet(views: 2)), - TopListData.ArchiveItem(href: "http://example.com/wp-admin/revision.php?revision=12345", value: "/wp-admin/revision.php?revision=12345", metrics: SiteMetricsSet(views: 2)) - ], - metrics: SiteMetricsSet(views: 25) // Total views for the section - ) - - let authorSection = TopListData.ArchiveSection( - sectionName: "author", - items: [ - TopListData.ArchiveItem(href: "http://example.com/author/johndoe/", value: "johndoe", metrics: SiteMetricsSet(views: 31)), - TopListData.ArchiveItem(href: "http://example.com/author/janedoe/", value: "janedoe", metrics: SiteMetricsSet(views: 5)), - TopListData.ArchiveItem(href: "http://example.com/author/testuser/", value: "testuser", metrics: SiteMetricsSet(views: 2)) - ], - metrics: SiteMetricsSet(views: 40) // Total views for the section - ) - - return [otherSection, authorSection] - } } diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift index a454bf0e47a0..285b8be7bb13 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift @@ -49,10 +49,6 @@ struct TopListItemsView: View { .transition(.move(edge: .leading) .combined(with: .scale(scale: 0.75)) .combined(with: .opacity)) -// .transition(.asymmetric( -// insertion: .opacity.combined(with: .move(edge: .top)), -// removal: .opacity.combined(with: .move(edge: .top)) -// )) } } } From ef26441097fbabc45992b7fc3ce7b36f4765fd2b Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 25 Jul 2025 10:39:50 -0400 Subject: [PATCH 073/349] 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 074/349] 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 075/349] 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 076/349] 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 077/349] 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 078/349] 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 079/349] 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 080/349] 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 081/349] 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 { From 7ded9cdf05faa08dc62e64ec59a74985548cf0e0 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 25 Jul 2025 14:28:18 -0400 Subject: [PATCH 082/349] Fix order of supported item in StatsService --- Modules/Sources/JetpackStats/Services/StatsService.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Modules/Sources/JetpackStats/Services/StatsService.swift b/Modules/Sources/JetpackStats/Services/StatsService.swift index d55c5d58a323..dc47e2786cc4 100644 --- a/Modules/Sources/JetpackStats/Services/StatsService.swift +++ b/Modules/Sources/JetpackStats/Services/StatsService.swift @@ -24,8 +24,8 @@ actor StatsService: StatsServiceProtocol { ] let supportedItems: [TopListItemType] = [ - .postsAndPages, .archive, .referrers, .locations, .authors, .externalLinks, - .fileDownloads, .searchTerms, .videos + .postsAndPages, .authors, .referrers, .locations, + .externalLinks, .fileDownloads, .searchTerms, .videos, .archive ] nonisolated func getSupportedMetrics(for item: TopListItemType) -> [SiteMetric] { From 653fd3b6d99efd822de3ff6b7e6f3c0855905316 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 25 Jul 2025 14:29:28 -0400 Subject: [PATCH 083/349] Fix selectable item type in AuthorStatsView --- Modules/Sources/JetpackStats/Screens/AuthorStatsView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Modules/Sources/JetpackStats/Screens/AuthorStatsView.swift b/Modules/Sources/JetpackStats/Screens/AuthorStatsView.swift index 88d44f1a2f83..13984556bb3f 100644 --- a/Modules/Sources/JetpackStats/Screens/AuthorStatsView.swift +++ b/Modules/Sources/JetpackStats/Screens/AuthorStatsView.swift @@ -23,6 +23,7 @@ struct AuthorStatsView: View { selection: .init(item: .postsAndPages, metric: .views), dateRange: range, service: context.service, + items: [.postsAndPages], fetchLimit: 32, filter: .author(userId: author.userId) )) From fbd60b5e859a3fca4ed2819ee713d93b5e87f608 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 25 Jul 2025 14:30:06 -0400 Subject: [PATCH 084/349] Fix StatsService not sending parametrs --- Modules/Sources/JetpackStats/Services/StatsService.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/JetpackStats/Services/StatsService.swift b/Modules/Sources/JetpackStats/Services/StatsService.swift index dc47e2786cc4..f758a7c209a8 100644 --- a/Modules/Sources/JetpackStats/Services/StatsService.swift +++ b/Modules/Sources/JetpackStats/Services/StatsService.swift @@ -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, limit: limit ?? 10) + return try await service.getData(interval: interval, unit: .day, summarize: true, limit: limit ?? 10, parameters: parameters) } switch item { From 417e6617955d8c1ab3b038a6dd9f38ce90c6eead Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 25 Jul 2025 14:36:14 -0400 Subject: [PATCH 085/349] Add PostStatsMetricsStripView anmatinos --- .../JetpackStats/Screens/PostStatsView.swift | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/Modules/Sources/JetpackStats/Screens/PostStatsView.swift b/Modules/Sources/JetpackStats/Screens/PostStatsView.swift index f697f324a062..1a07973deb84 100644 --- a/Modules/Sources/JetpackStats/Screens/PostStatsView.swift +++ b/Modules/Sources/JetpackStats/Screens/PostStatsView.swift @@ -130,19 +130,17 @@ public struct PostStatsView: View { Divider() - if let metrics { + if let error { + SimpleErrorView(error: error) + .frame(minHeight: 210) + } else { PostStatsMetricsStripView( - metrics: metrics, + metrics: metrics ?? .mock, onLikesTapped: navigateToLikesList, onCommentsTapped: navigateToCommentsList ) - } else if isLoadingDetails { - PostStatsMetricsStripView(metrics: .mock, onLikesTapped: nil, onCommentsTapped: nil) - .redacted(reason: .placeholder) - } else if let error { - SimpleErrorView(error: error) - .frame(minHeight: 200) - + // Preserving view identity for better animations + .redacted(reason: metrics == nil ? .placeholder : []) } } .frame(maxWidth: .infinity, alignment: .leading) From 667c8adebab218cc31a450d0fd465d8701244bb4 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 25 Jul 2025 14:38:44 -0400 Subject: [PATCH 086/349] Fix heatmapColor empty state in dark mode --- Modules/Sources/JetpackStats/Constants.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Modules/Sources/JetpackStats/Constants.swift b/Modules/Sources/JetpackStats/Constants.swift index 95bc9cea5e5a..83e956acd930 100644 --- a/Modules/Sources/JetpackStats/Constants.swift +++ b/Modules/Sources/JetpackStats/Constants.swift @@ -44,7 +44,10 @@ enum Constants { static func heatmapColor(baseColor: Color, intensity: Double) -> Color { if intensity == 0 { - return Color(UIColor.secondarySystemBackground) + return Color(UIColor( + light: UIColor.secondarySystemBackground, + dark: UIColor.tertiarySystemBackground + )) } // Use graduated opacity based on intensity From 6d88af87c8a7ab1abd64de504e920e6c15bb9e14 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 25 Jul 2025 14:47:50 -0400 Subject: [PATCH 087/349] Reserve space in TopListItemView to prevent animation issues when switcing periods --- .../TopList/Rows/TopListPostRowView.swift | 1 - .../Views/TopList/TopListItemView.swift | 25 +++++++++++++------ .../Views/TopList/TopListMetricsView.swift | 1 - 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListPostRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListPostRowView.swift index 58bc6823f322..f8cf4859b9ef 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListPostRowView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListPostRowView.swift @@ -17,7 +17,6 @@ struct TopListPostRowView: View { .font(.callout) .foregroundColor(.primary) .lineLimit(2) - .padding(.trailing, 4) } } } diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift index ddf65b9b002c..78b93befbe62 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift @@ -51,16 +51,25 @@ struct TopListItemView: View { EmptyView() } - Spacer(minLength: 4) + Spacer(minLength: 6) // Metrics view - TopListMetricsView( - currentValue: currentItem.metrics[metric] ?? 0, - previousValue: previousItem?.metrics[metric], - metric: metric, - showDetails: showDetails, - showChevron: hasDetails - ) + ZStack { + if previousItem != nil { + // Reserve space to avoid junky animations when changing period + Text("+4.8K (31.2%)") + .font(.caption.weight(.medium)).tracking(-0.33) + .opacity(0) + } + + TopListMetricsView( + currentValue: currentItem.metrics[metric] ?? 0, + previousValue: previousItem?.metrics[metric], + metric: metric, + showDetails: showDetails, + showChevron: hasDetails + ) + } } .padding(.vertical, 7) .background( diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListMetricsView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListMetricsView.swift index 2f0523193f38..c1d6c919c734 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListMetricsView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListMetricsView.swift @@ -23,7 +23,6 @@ struct TopListMetricsView: View { } if showDetails, let trend { Text(trend.formattedTrend) - .fixedSize() .foregroundColor(trend.sentiment.foregroundColor) .contentTransition(.numericText()) .font(.caption.weight(.medium)).tracking(-0.33) From 495fe8d22710e5b9dfecfc5183ce86a59dacb3a8 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 25 Jul 2025 15:00:47 -0400 Subject: [PATCH 088/349] Add more fields to TopListData.Referrer including icons --- .../HistoricalData/historical-referrers.json | 20 ++++++++ .../RealtimeData/realtime-referrers.json | 16 +++++++ .../Services/Data/TopListChartData.swift | 2 + .../Data/TopListData+WordPressKit.swift | 2 + .../Services/Data/TopListData.swift | 2 + .../TopList/Rows/TopListReferrerRowView.swift | 46 +++++++++++++++---- 6 files changed, 78 insertions(+), 10 deletions(-) diff --git a/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-referrers.json b/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-referrers.json index 28ad1104d109..19fce64c15bc 100644 --- a/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-referrers.json +++ b/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-referrers.json @@ -2,6 +2,8 @@ { "name": "Google Search", "domain": "google.com", + "iconURL": "https://www.google.com/favicon.ico", + "isSpam": false, "metrics": { "views": 5000, "visitors": 4000, @@ -14,6 +16,8 @@ { "name": "Twitter/X", "domain": "twitter.com", + "iconURL": "https://abs.twimg.com/favicons/twitter.3.ico", + "isSpam": false, "metrics": { "views": 3500, "visitors": 2800, @@ -26,6 +30,8 @@ { "name": "Reddit", "domain": "reddit.com", + "iconURL": "https://www.redditstatic.com/desktop2x/img/favicon/favicon-32x32.png", + "isSpam": false, "metrics": { "views": 2000, "visitors": 1600, @@ -38,6 +44,8 @@ { "name": "Facebook", "domain": "facebook.com", + "iconURL": "https://static.xx.fbcdn.net/rsrc.php/yD/r/d4ZIVX-5C-b.ico", + "isSpam": false, "metrics": { "views": 1500, "visitors": 1200, @@ -50,6 +58,8 @@ { "name": "YouTube", "domain": "youtube.com", + "iconURL": "https://www.youtube.com/s/desktop/8a0c81e8/img/favicon_32x32.png", + "isSpam": false, "metrics": { "views": 1100, "visitors": 880, @@ -62,6 +72,8 @@ { "name": "LinkedIn", "domain": "linkedin.com", + "iconURL": "https://static.licdn.com/sc/h/al2o9zrvru7aqj8e1x2rzsrca", + "isSpam": false, "metrics": { "views": 900, "visitors": 720, @@ -74,6 +86,8 @@ { "name": "Hacker News", "domain": "news.ycombinator.com", + "iconURL": "https://news.ycombinator.com/favicon.ico", + "isSpam": false, "metrics": { "views": 800, "visitors": 640, @@ -86,6 +100,8 @@ { "name": "Flipboard", "domain": "flipboard.com", + "iconURL": "https://s.flipboard.com/webapp/images/favicon/favicon-32x32.png", + "isSpam": false, "metrics": { "views": 600, "visitors": 480, @@ -98,6 +114,8 @@ { "name": "Feedly", "domain": "feedly.com", + "iconURL": "https://s3.feedly.com/img/feedly-512.png", + "isSpam": false, "metrics": { "views": 500, "visitors": 400, @@ -110,6 +128,8 @@ { "name": "Apple News", "domain": "apple.news", + "iconURL": null, + "isSpam": false, "metrics": { "views": 400, "visitors": 320, diff --git a/Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-referrers.json b/Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-referrers.json index da91424996ff..4aa4d27ae779 100644 --- a/Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-referrers.json +++ b/Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-referrers.json @@ -2,6 +2,8 @@ { "name": "Google Search", "domain": "google.com", + "iconURL": "https://www.google.com/favicon.ico", + "isSpam": false, "metrics": { "views": 220 } @@ -9,6 +11,8 @@ { "name": "Twitter/X", "domain": "twitter.com", + "iconURL": "https://abs.twimg.com/favicons/twitter.3.ico", + "isSpam": false, "metrics": { "views": 160 } @@ -16,6 +20,8 @@ { "name": "Reddit", "domain": "reddit.com", + "iconURL": "https://www.redditstatic.com/desktop2x/img/favicon/favicon-32x32.png", + "isSpam": false, "metrics": { "views": 130 } @@ -23,6 +29,8 @@ { "name": "Hacker News", "domain": "news.ycombinator.com", + "iconURL": "https://news.ycombinator.com/favicon.ico", + "isSpam": false, "metrics": { "views": 100 } @@ -30,6 +38,8 @@ { "name": "Direct Traffic", "domain": "direct", + "iconURL": null, + "isSpam": false, "metrics": { "views": 85 } @@ -37,6 +47,8 @@ { "name": "LinkedIn", "domain": "linkedin.com", + "iconURL": "https://static.licdn.com/sc/h/al2o9zrvru7aqj8e1x2rzsrca", + "isSpam": false, "metrics": { "views": 70 } @@ -44,6 +56,8 @@ { "name": "Facebook", "domain": "facebook.com", + "iconURL": "https://static.xx.fbcdn.net/rsrc.php/yD/r/d4ZIVX-5C-b.ico", + "isSpam": false, "metrics": { "views": 60 } @@ -51,6 +65,8 @@ { "name": "YouTube", "domain": "youtube.com", + "iconURL": "https://www.youtube.com/s/desktop/8a0c81e8/img/favicon_32x32.png", + "isSpam": false, "metrics": { "views": 50 } diff --git a/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift b/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift index 66346249f9cb..534c84780f95 100644 --- a/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift +++ b/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift @@ -131,6 +131,8 @@ extension TopListChartData { return TopListData.Referrer( name: data.0, domain: data.1, + iconURL: nil, + isSpam: data.0 == "Facebook", metrics: metrics ) } diff --git a/Modules/Sources/JetpackStats/Services/Data/TopListData+WordPressKit.swift b/Modules/Sources/JetpackStats/Services/Data/TopListData+WordPressKit.swift index e86c34d5b3ca..33afd35625f5 100644 --- a/Modules/Sources/JetpackStats/Services/Data/TopListData+WordPressKit.swift +++ b/Modules/Sources/JetpackStats/Services/Data/TopListData+WordPressKit.swift @@ -20,6 +20,8 @@ extension TopListData.Referrer { self.init( name: referrer.title, domain: referrer.url?.host, + iconURL: referrer.iconURL, + isSpam: referrer.isSpam, metrics: SiteMetricsSet(views: referrer.viewsCount) ) } diff --git a/Modules/Sources/JetpackStats/Services/Data/TopListData.swift b/Modules/Sources/JetpackStats/Services/Data/TopListData.swift index 946507f42a15..05a2b46ed2c4 100644 --- a/Modules/Sources/JetpackStats/Services/Data/TopListData.swift +++ b/Modules/Sources/JetpackStats/Services/Data/TopListData.swift @@ -41,6 +41,8 @@ extension TopListData { struct Referrer: Codable, TopListItem { let name: String let domain: String? + let iconURL: URL? + let isSpam: Bool? var metrics: SiteMetricsSet var id: TopListItemID { diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListReferrerRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListReferrerRowView.swift index 768a863d3a34..76bebd2ed88b 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListReferrerRowView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListReferrerRowView.swift @@ -1,22 +1,48 @@ import SwiftUI +import WordPressUI struct TopListReferrerRowView: View { let item: TopListData.Referrer let showDetails: Bool var body: some View { - VStack(alignment: .leading, spacing: 2) { - Text(item.name) - .font(.callout) - .foregroundColor(.primary) - .lineLimit(1) - - if showDetails, let domain = item.domain { - Text(domain) - .font(.caption) - .foregroundColor(.secondary) + HStack(spacing: 8) { + // Icon or placeholder + if let iconURL = item.iconURL { + CachedAsyncImage(url: iconURL) { image in + image + .resizable() + .aspectRatio(contentMode: .fit) + } placeholder: { + placeholderIcon + } + .frame(width: 24, height: 24) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } else { + placeholderIcon + .frame(width: 24, height: 24) + } + + VStack(alignment: .leading, spacing: 2) { + Text(item.name) + .font(.callout) + .foregroundColor(.primary) .lineLimit(1) + + if showDetails, let domain = item.domain { + Text(domain) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } } } } + + private var placeholderIcon: some View { + Image(systemName: "link.circle.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(.secondary.opacity(0.5)) + } } From 78487ad8676970df72d2b4e550fed838c5199ce7 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 25 Jul 2025 15:12:59 -0400 Subject: [PATCH 089/349] Remove showDetails from TopList --- .../Cards/RealtimeTopListCard.swift | 3 +-- .../Rows/TopListArchiveItemRowView.swift | 1 - .../TopList/Rows/TopListAuthorRowView.swift | 3 +-- .../TopListExpandableSectionRowView.swift | 1 - .../Rows/TopListExternalLinkRowView.swift | 11 ++++------ .../Rows/TopListFileDownloadRowView.swift | 3 +-- .../TopList/Rows/TopListLocationRowView.swift | 3 +-- .../TopList/Rows/TopListPostRowView.swift | 1 - .../TopList/Rows/TopListReferrerRowView.swift | 3 +-- .../Rows/TopListSearchTermRowView.swift | 11 ++++------ .../TopList/Rows/TopListVideoRowView.swift | 11 ++++------ .../Views/TopList/TopListItemView.swift | 20 +++++++++---------- .../Views/TopList/TopListItemsView.swift | 4 ---- .../Views/TopList/TopListMetricsView.swift | 3 +-- 14 files changed, 27 insertions(+), 51 deletions(-) diff --git a/Modules/Sources/JetpackStats/Cards/RealtimeTopListCard.swift b/Modules/Sources/JetpackStats/Cards/RealtimeTopListCard.swift index 1edc401be15d..300f86951b84 100644 --- a/Modules/Sources/JetpackStats/Cards/RealtimeTopListCard.swift +++ b/Modules/Sources/JetpackStats/Cards/RealtimeTopListCard.swift @@ -102,8 +102,7 @@ struct RealtimeTopListCard: View { return TopListItemsView( data: chartData, itemLimit: 6, - dateRange: context.calendar.makeDateRange(for: .today), - showDetails: false + dateRange: context.calendar.makeDateRange(for: .today) ) } diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListArchiveItemRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListArchiveItemRowView.swift index 5bf9987f71bc..586e3ebd8e17 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListArchiveItemRowView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListArchiveItemRowView.swift @@ -2,7 +2,6 @@ import SwiftUI struct TopListArchiveItemRowView: View { let item: TopListData.ArchiveItem - let showDetails: Bool var body: some View { VStack(alignment: .leading, spacing: 2) { diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListAuthorRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListAuthorRowView.swift index 75b0d451f954..3124ad92586f 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListAuthorRowView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListAuthorRowView.swift @@ -2,7 +2,6 @@ import SwiftUI struct TopListAuthorRowView: View { let item: TopListData.Author - let showDetails: Bool var body: some View { HStack(spacing: 12) { @@ -14,7 +13,7 @@ struct TopListAuthorRowView: View { .foregroundColor(.primary) .lineLimit(1) - if showDetails, let role = item.role { + if let role = item.role { Text(role) .font(.caption) .foregroundColor(.secondary) diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListExpandableSectionRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListExpandableSectionRowView.swift index 185820426258..af389aaf32a3 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListExpandableSectionRowView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListExpandableSectionRowView.swift @@ -2,7 +2,6 @@ import SwiftUI struct TopListExpandableSectionRowView: View { let item: any TopListExpandableItem - let showDetails: Bool var isExpanded: Bool = false var body: some View { diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListExternalLinkRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListExternalLinkRowView.swift index 3eb45f26449b..e2e28c1078bf 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListExternalLinkRowView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListExternalLinkRowView.swift @@ -2,7 +2,6 @@ import SwiftUI struct TopListExternalLinkRowView: View { let item: TopListData.ExternalLink - let showDetails: Bool var body: some View { HStack(spacing: 12) { @@ -17,12 +16,10 @@ struct TopListExternalLinkRowView: View { .foregroundColor(.primary) .lineLimit(1) - if showDetails { - Text(item.url) - .font(.caption) - .foregroundColor(.secondary) - .lineLimit(1) - } + Text(item.url) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) } } } diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListFileDownloadRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListFileDownloadRowView.swift index 1183e149e5d9..feb17e91279e 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListFileDownloadRowView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListFileDownloadRowView.swift @@ -2,7 +2,6 @@ import SwiftUI struct TopListFileDownloadRowView: View { let item: TopListData.FileDownload - let showDetails: Bool var body: some View { VStack(alignment: .leading, spacing: 2) { @@ -11,7 +10,7 @@ struct TopListFileDownloadRowView: View { .foregroundColor(.primary) .lineLimit(1) - if showDetails, let filePath = item.filePath { + if let filePath = item.filePath { Text(verbatim: filePath) .font(.caption) .foregroundColor(.secondary) diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListLocationRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListLocationRowView.swift index a4372c3c16d0..137c9bcea2d5 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListLocationRowView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListLocationRowView.swift @@ -2,7 +2,6 @@ import SwiftUI struct TopListLocationRowView: View { let item: TopListData.Location - let showDetails: Bool var body: some View { VStack(alignment: .leading, spacing: 2) { @@ -17,7 +16,7 @@ struct TopListLocationRowView: View { .lineLimit(1) } - if showDetails, let countryCode = item.countryCode { + if let countryCode = item.countryCode { Text(countryCode) .font(.caption) .foregroundColor(.secondary) diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListPostRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListPostRowView.swift index f8cf4859b9ef..d00440ecac0e 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListPostRowView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListPostRowView.swift @@ -3,7 +3,6 @@ import WordPressShared struct TopListPostRowView: View { let item: TopListData.Post - let showDetails: Bool var body: some View { VStack(alignment: .leading, spacing: 2) { diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListReferrerRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListReferrerRowView.swift index 76bebd2ed88b..2f249c38cc52 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListReferrerRowView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListReferrerRowView.swift @@ -3,7 +3,6 @@ import WordPressUI struct TopListReferrerRowView: View { let item: TopListData.Referrer - let showDetails: Bool var body: some View { HStack(spacing: 8) { @@ -29,7 +28,7 @@ struct TopListReferrerRowView: View { .foregroundColor(.primary) .lineLimit(1) - if showDetails, let domain = item.domain { + if let domain = item.domain { Text(domain) .font(.caption) .foregroundColor(.secondary) diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListSearchTermRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListSearchTermRowView.swift index 4513080ab3b9..56bc0db7eedc 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListSearchTermRowView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListSearchTermRowView.swift @@ -2,7 +2,6 @@ import SwiftUI struct TopListSearchTermRowView: View { let item: TopListData.SearchTerm - let showDetails: Bool var body: some View { VStack(alignment: .leading, spacing: 2) { @@ -11,12 +10,10 @@ struct TopListSearchTermRowView: View { .foregroundColor(.primary) .lineLimit(1) - if showDetails { - Text(Strings.SearchTerms.fromSearch) - .font(.caption) - .foregroundColor(.secondary) - .lineLimit(1) - } + Text(Strings.SearchTerms.fromSearch) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) } } } diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListVideoRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListVideoRowView.swift index 8179dd7a7af6..d25bad4b03dc 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListVideoRowView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListVideoRowView.swift @@ -2,7 +2,6 @@ import SwiftUI struct TopListVideoRowView: View { let item: TopListData.Video - let showDetails: Bool var body: some View { VStack(alignment: .leading, spacing: 2) { @@ -17,12 +16,10 @@ struct TopListVideoRowView: View { .lineLimit(1) } - if showDetails { - Text(Strings.Videos.postId(item.postId)) - .font(.caption) - .foregroundColor(.secondary) - .lineLimit(1) - } + Text(Strings.Videos.postId(item.postId)) + .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 78b93befbe62..1b12bb27a464 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift @@ -5,7 +5,6 @@ struct TopListItemView: View { let previousItem: (any TopListItem)? let metric: SiteMetric let maxValue: Int - let showDetails: Bool let dateRange: StatsDateRange @Environment(\.router) private var router @@ -29,23 +28,23 @@ struct TopListItemView: View { // Content-specific view switch currentItem { case let post as TopListData.Post: - TopListPostRowView(item: post, showDetails: showDetails) + TopListPostRowView(item: post) case let referrer as TopListData.Referrer: - TopListReferrerRowView(item: referrer, showDetails: showDetails) + TopListReferrerRowView(item: referrer) case let location as TopListData.Location: - TopListLocationRowView(item: location, showDetails: showDetails) + TopListLocationRowView(item: location) case let author as TopListData.Author: - TopListAuthorRowView(item: author, showDetails: showDetails) + TopListAuthorRowView(item: author) case let link as TopListData.ExternalLink: - TopListExternalLinkRowView(item: link, showDetails: showDetails) + TopListExternalLinkRowView(item: link) case let download as TopListData.FileDownload: - TopListFileDownloadRowView(item: download, showDetails: showDetails) + TopListFileDownloadRowView(item: download) case let searchTerm as TopListData.SearchTerm: - TopListSearchTermRowView(item: searchTerm, showDetails: showDetails) + TopListSearchTermRowView(item: searchTerm) case let video as TopListData.Video: - TopListVideoRowView(item: video, showDetails: showDetails) + TopListVideoRowView(item: video) case let archiveItem as TopListData.ArchiveItem: - TopListArchiveItemRowView(item: archiveItem, showDetails: showDetails) + TopListArchiveItemRowView(item: archiveItem) default: let _ = assertionFailure("unsupported item: \(currentItem)") EmptyView() @@ -66,7 +65,6 @@ struct TopListItemView: View { currentValue: currentItem.metrics[metric] ?? 0, previousValue: previousItem?.metrics[metric], metric: metric, - showDetails: showDetails, showChevron: hasDetails ) } diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift index 285b8be7bb13..5e2a9e9d6fe6 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift @@ -4,7 +4,6 @@ struct TopListItemsView: View { let data: TopListChartData let itemLimit: Int let dateRange: StatsDateRange - var showDetails = true @State private var expandedSections: Set = [] @@ -61,7 +60,6 @@ struct TopListItemsView: View { previousItem: data.previousItem(for: item), metric: data.metric, maxValue: data.maxValue, - showDetails: showDetails, dateRange: dateRange ) } @@ -87,7 +85,6 @@ private struct ExpandableItemView: View { HStack(spacing: 0) { TopListExpandableSectionRowView( item: section as any TopListExpandableItem, - showDetails: false, isExpanded: isExpanded ) @@ -97,7 +94,6 @@ private struct ExpandableItemView: View { currentValue: section.metrics[metric] ?? 0, previousValue: previousItem?.metrics[metric], metric: metric, - showDetails: false, showChevron: false ) } diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListMetricsView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListMetricsView.swift index c1d6c919c734..daf80aa6910a 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListMetricsView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListMetricsView.swift @@ -4,7 +4,6 @@ struct TopListMetricsView: View { let currentValue: Int let previousValue: Int? let metric: SiteMetric - var showDetails = true var showChevron = false var body: some View { @@ -21,7 +20,7 @@ struct TopListMetricsView: View { .foregroundStyle(Color(.tertiaryLabel)) } } - if showDetails, let trend { + if let trend { Text(trend.formattedTrend) .foregroundColor(trend.sentiment.foregroundColor) .contentTransition(.numericText()) From 61dafe31dbf333902fee5f8fb6aef22c47c14e4f Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 25 Jul 2025 15:31:41 -0400 Subject: [PATCH 090/349] Add initial ReferrerStatsView implementation --- .../HistoricalData/historical-referrers.json | 128 ++++++--- .../RealtimeData/realtime-referrers.json | 60 +++++ .../Screens/ReferrerStatsView.swift | 245 ++++++++++++++++++ .../Services/Data/TopListChartData.swift | 26 ++ .../Data/TopListData+WordPressKit.swift | 1 + .../Services/Data/TopListData.swift | 1 + .../Views/TopList/TopListItemView.swift | 7 + 7 files changed, 425 insertions(+), 43 deletions(-) create mode 100644 Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift diff --git a/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-referrers.json b/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-referrers.json index 19fce64c15bc..0eede38620c9 100644 --- a/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-referrers.json +++ b/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-referrers.json @@ -4,6 +4,38 @@ "domain": "google.com", "iconURL": "https://www.google.com/favicon.ico", "isSpam": false, + "children": [ + { + "name": "wordpress blog best practices", + "domain": "google.com", + "iconURL": "https://www.google.com/favicon.ico", + "isSpam": false, + "children": [], + "metrics": { + "views": 1200, + "visitors": 960, + "bounceRate": 22, + "timeOnSite": 320, + "comments": 45, + "likes": 105 + } + }, + { + "name": "how to optimize wordpress site", + "domain": "google.com", + "iconURL": "https://www.google.com/favicon.ico", + "isSpam": false, + "children": [], + "metrics": { + "views": 950, + "visitors": 760, + "bounceRate": 25, + "timeOnSite": 290, + "comments": 35, + "likes": 82 + } + } + ], "metrics": { "views": 5000, "visitors": 4000, @@ -18,6 +50,53 @@ "domain": "twitter.com", "iconURL": "https://abs.twimg.com/favicons/twitter.3.ico", "isSpam": false, + "children": [ + { + "name": "@johndoe shared your post", + "domain": "twitter.com", + "iconURL": "https://abs.twimg.com/favicons/twitter.3.ico", + "isSpam": false, + "children": [], + "metrics": { + "views": 850, + "visitors": 680, + "bounceRate": 32, + "timeOnSite": 240, + "comments": 38, + "likes": 89 + } + }, + { + "name": "@webdev tweeted about your article", + "domain": "twitter.com", + "iconURL": "https://abs.twimg.com/favicons/twitter.3.ico", + "isSpam": false, + "children": [], + "metrics": { + "views": 720, + "visitors": 576, + "bounceRate": 35, + "timeOnSite": 210, + "comments": 32, + "likes": 72 + } + }, + { + "name": "#WordPress trending topic", + "domain": "twitter.com", + "iconURL": "https://abs.twimg.com/favicons/twitter.3.ico", + "isSpam": false, + "children": [], + "metrics": { + "views": 600, + "visitors": 480, + "bounceRate": 38, + "timeOnSite": 195, + "comments": 25, + "likes": 60 + } + } + ], "metrics": { "views": 3500, "visitors": 2800, @@ -32,6 +111,7 @@ "domain": "reddit.com", "iconURL": "https://www.redditstatic.com/desktop2x/img/favicon/favicon-32x32.png", "isSpam": false, + "children": [], "metrics": { "views": 2000, "visitors": 1600, @@ -46,6 +126,7 @@ "domain": "facebook.com", "iconURL": "https://static.xx.fbcdn.net/rsrc.php/yD/r/d4ZIVX-5C-b.ico", "isSpam": false, + "children": [], "metrics": { "views": 1500, "visitors": 1200, @@ -60,6 +141,7 @@ "domain": "youtube.com", "iconURL": "https://www.youtube.com/s/desktop/8a0c81e8/img/favicon_32x32.png", "isSpam": false, + "children": [], "metrics": { "views": 1100, "visitors": 880, @@ -74,6 +156,7 @@ "domain": "linkedin.com", "iconURL": "https://static.licdn.com/sc/h/al2o9zrvru7aqj8e1x2rzsrca", "isSpam": false, + "children": [], "metrics": { "views": 900, "visitors": 720, @@ -88,6 +171,7 @@ "domain": "news.ycombinator.com", "iconURL": "https://news.ycombinator.com/favicon.ico", "isSpam": false, + "children": [], "metrics": { "views": 800, "visitors": 640, @@ -96,47 +180,5 @@ "comments": 75, "likes": 180 } - }, - { - "name": "Flipboard", - "domain": "flipboard.com", - "iconURL": "https://s.flipboard.com/webapp/images/favicon/favicon-32x32.png", - "isSpam": false, - "metrics": { - "views": 600, - "visitors": 480, - "bounceRate": 50, - "timeOnSite": 140, - "comments": 35, - "likes": 95 - } - }, - { - "name": "Feedly", - "domain": "feedly.com", - "iconURL": "https://s3.feedly.com/img/feedly-512.png", - "isSpam": false, - "metrics": { - "views": 500, - "visitors": 400, - "bounceRate": 42, - "timeOnSite": 200, - "comments": 30, - "likes": 85 - } - }, - { - "name": "Apple News", - "domain": "apple.news", - "iconURL": null, - "isSpam": false, - "metrics": { - "views": 400, - "visitors": 320, - "bounceRate": 48, - "timeOnSite": 150, - "comments": 25, - "likes": 70 - } } -] \ No newline at end of file +] diff --git a/Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-referrers.json b/Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-referrers.json index 4aa4d27ae779..0f0d0218df1b 100644 --- a/Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-referrers.json +++ b/Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-referrers.json @@ -4,6 +4,38 @@ "domain": "google.com", "iconURL": "https://www.google.com/favicon.ico", "isSpam": false, + "children": [ + { + "name": "wordpress development tutorial", + "domain": "google.com", + "iconURL": "https://www.google.com/favicon.ico", + "isSpam": false, + "children": [], + "metrics": { + "views": 85 + } + }, + { + "name": "swift programming blog", + "domain": "google.com", + "iconURL": "https://www.google.com/favicon.ico", + "isSpam": false, + "children": [], + "metrics": { + "views": 75 + } + }, + { + "name": "ios app development", + "domain": "google.com", + "iconURL": "https://www.google.com/favicon.ico", + "isSpam": false, + "children": [], + "metrics": { + "views": 60 + } + } + ], "metrics": { "views": 220 } @@ -13,6 +45,28 @@ "domain": "twitter.com", "iconURL": "https://abs.twimg.com/favicons/twitter.3.ico", "isSpam": false, + "children": [ + { + "name": "@wordpress shared your post", + "domain": "twitter.com", + "iconURL": "https://abs.twimg.com/favicons/twitter.3.ico", + "isSpam": false, + "children": [], + "metrics": { + "views": 100 + } + }, + { + "name": "@techblogger mentioned you", + "domain": "twitter.com", + "iconURL": "https://abs.twimg.com/favicons/twitter.3.ico", + "isSpam": false, + "children": [], + "metrics": { + "views": 60 + } + } + ], "metrics": { "views": 160 } @@ -22,6 +76,7 @@ "domain": "reddit.com", "iconURL": "https://www.redditstatic.com/desktop2x/img/favicon/favicon-32x32.png", "isSpam": false, + "children": [], "metrics": { "views": 130 } @@ -31,6 +86,7 @@ "domain": "news.ycombinator.com", "iconURL": "https://news.ycombinator.com/favicon.ico", "isSpam": false, + "children": [], "metrics": { "views": 100 } @@ -40,6 +96,7 @@ "domain": "direct", "iconURL": null, "isSpam": false, + "children": [], "metrics": { "views": 85 } @@ -49,6 +106,7 @@ "domain": "linkedin.com", "iconURL": "https://static.licdn.com/sc/h/al2o9zrvru7aqj8e1x2rzsrca", "isSpam": false, + "children": [], "metrics": { "views": 70 } @@ -58,6 +116,7 @@ "domain": "facebook.com", "iconURL": "https://static.xx.fbcdn.net/rsrc.php/yD/r/d4ZIVX-5C-b.ico", "isSpam": false, + "children": [], "metrics": { "views": 60 } @@ -67,6 +126,7 @@ "domain": "youtube.com", "iconURL": "https://www.youtube.com/s/desktop/8a0c81e8/img/favicon_32x32.png", "isSpam": false, + "children": [], "metrics": { "views": 50 } diff --git a/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift b/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift new file mode 100644 index 000000000000..8396b1962e6c --- /dev/null +++ b/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift @@ -0,0 +1,245 @@ +import SwiftUI +import WordPressUI + +struct ReferrerStatsView: View { + let referrer: TopListData.Referrer + + @Environment(\.router) private var router + + var body: some View { + ScrollView { + VStack(spacing: Constants.step2) { + headerCard + + if !referrer.children.isEmpty { + childrenCard + } + } + .padding(.horizontal, Constants.step2) + .padding(.vertical, Constants.step1) + } + .background(Color(.systemGroupedBackground)) + .navigationTitle(Strings.ReferrerStats.title) + .navigationBarTitleDisplayMode(.inline) + } + + private var placeholderIcon: some View { + Image(systemName: "link.circle.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(.secondary.opacity(0.5)) + } +} + +// MARK: - Subviews + +private extension ReferrerStatsView { + var headerCard: some View { + VStack(spacing: Constants.step1) { + referrerInfoRow + + Divider() + + markAsSpamButton + } + .padding(Constants.step2) + .cardStyle() + } + + var referrerInfoRow: some View { + HStack(spacing: Constants.step1) { + referrerIcon + + referrerDetails + + Spacer() + + viewsCount + } + } + + @ViewBuilder + var referrerIcon: some View { + if let iconURL = referrer.iconURL { + CachedAsyncImage(url: iconURL) { image in + image + .resizable() + .aspectRatio(contentMode: .fit) + } placeholder: { + placeholderIcon + } + .frame(width: 48, height: 48) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } else { + placeholderIcon + .frame(width: 48, height: 48) + } + } + + var referrerDetails: some View { + VStack(alignment: .leading, spacing: 4) { + Text(referrer.name) + .font(.headline) + .foregroundColor(.primary) + + if let domain = referrer.domain { + Text(domain) + .font(.subheadline) + .foregroundColor(.secondary) + } + } + } + + @ViewBuilder + var viewsCount: some View { + if let views = referrer.metrics.views { + VStack(alignment: .trailing, spacing: 4) { + Text(StatsValueFormatter.formatNumber(views)) + .font(.title2.weight(.semibold)) + .foregroundColor(.primary) + Text(SiteMetric.views.localizedTitle) + .font(.caption) + .foregroundColor(.secondary) + } + } + } + + var markAsSpamButton: some View { + Button { + // TODO: Implement mark as spam functionality + } label: { + Label(Strings.ReferrerStats.markAsSpam, systemImage: "exclamationmark.triangle") + .font(.subheadline.weight(.medium)) + .foregroundColor(.red) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + } + .buttonStyle(.plain) + } + + var childrenCard: some View { + VStack(alignment: .leading, spacing: 0) { + Text(Strings.ReferrerStats.referralSources) + .font(.headline) + .foregroundColor(.primary) + .padding(.horizontal, Constants.step2) + .padding(.vertical, Constants.step1) + + Divider() + + ForEach(referrer.children, id: \.id) { child in + childRow(for: child) + } + } + .cardStyle() + } + + func childRow(for child: TopListData.Referrer) -> some View { + VStack(spacing: 0) { + HStack { + childInfo(for: child) + + Spacer() + + if let views = child.metrics.views { + Text(StatsValueFormatter.formatNumber(views)) + .font(.subheadline.weight(.medium)) + .foregroundColor(.secondary) + } + } + .padding(.horizontal, Constants.step2) + .padding(.vertical, Constants.step1) + + if child.id != referrer.children.last?.id { + Divider() + .padding(.leading, Constants.step2) + } + } + } + + func childInfo(for child: TopListData.Referrer) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(child.name) + .font(.callout) + .foregroundColor(.primary) + .lineLimit(2) + + if let domain = child.domain, let url = URL(string: "https://\(domain)") { + Button { + router.openURL(url) + } label: { + Text(domain) + .font(.caption) + .underline() + } + .buttonStyle(.plain) + } + } + } +} + +// MARK: - Strings + +private extension Strings { + enum ReferrerStats { + static let title = NSLocalizedString( + "stats.referrer.title", + value: "Referrer Details", + comment: "Title for the referrer details screen" + ) + + static let markAsSpam = NSLocalizedString( + "stats.referrer.markAsSpam", + value: "Mark as Spam", + comment: "Button to mark a referrer as spam" + ) + + static let referralSources = NSLocalizedString( + "stats.referrer.referralSources", + value: "Referral Sources", + comment: "Section title for the list of referral sources" + ) + } +} + +// MARK: - Preview + +#Preview { + ReferrerStatsView(referrer: .mock) +} + +private extension TopListData.Referrer { + static let mock = TopListData.Referrer( + name: "Google Search", + domain: "google.com", + iconURL: URL(string: "https://www.google.com/favicon.ico"), + isSpam: false, + children: [ + TopListData.Referrer( + name: "wordpress development tutorial", + domain: "google.com", + iconURL: URL(string: "https://www.google.com/favicon.ico"), + isSpam: false, + children: [], + metrics: SiteMetricsSet(views: 850) + ), + TopListData.Referrer( + name: "swift programming blog", + domain: "google.com", + iconURL: URL(string: "https://www.google.com/favicon.ico"), + isSpam: false, + children: [], + metrics: SiteMetricsSet(views: 750) + ), + TopListData.Referrer( + name: "ios app development best practices", + domain: "google.com", + iconURL: URL(string: "https://www.google.com/favicon.ico"), + isSpam: false, + children: [], + metrics: SiteMetricsSet(views: 600) + ) + ], + metrics: SiteMetricsSet(views: 2200) + ) +} diff --git a/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift b/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift index 534c84780f95..59df3410a2f8 100644 --- a/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift +++ b/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift @@ -133,6 +133,32 @@ extension TopListChartData { domain: data.1, iconURL: nil, isSpam: data.0 == "Facebook", + children: [ + TopListData.Referrer( + name: "wordpress development tutorial", + domain: "google.com", + iconURL: URL(string: "https://www.google.com/favicon.ico"), + isSpam: false, + children: [], + metrics: SiteMetricsSet(views: 850) + ), + TopListData.Referrer( + name: "swift programming blog", + domain: "google.com", + iconURL: URL(string: "https://www.google.com/favicon.ico"), + isSpam: false, + children: [], + metrics: SiteMetricsSet(views: 750) + ), + TopListData.Referrer( + name: "ios app development best practices", + domain: "google.com", + iconURL: URL(string: "https://www.google.com/favicon.ico"), + isSpam: false, + children: [], + metrics: SiteMetricsSet(views: 600) + ) + ], metrics: metrics ) } diff --git a/Modules/Sources/JetpackStats/Services/Data/TopListData+WordPressKit.swift b/Modules/Sources/JetpackStats/Services/Data/TopListData+WordPressKit.swift index 33afd35625f5..26e0a990bf6e 100644 --- a/Modules/Sources/JetpackStats/Services/Data/TopListData+WordPressKit.swift +++ b/Modules/Sources/JetpackStats/Services/Data/TopListData+WordPressKit.swift @@ -22,6 +22,7 @@ extension TopListData.Referrer { domain: referrer.url?.host, iconURL: referrer.iconURL, isSpam: referrer.isSpam, + children: referrer.children.map { TopListData.Referrer($0) }, metrics: SiteMetricsSet(views: referrer.viewsCount) ) } diff --git a/Modules/Sources/JetpackStats/Services/Data/TopListData.swift b/Modules/Sources/JetpackStats/Services/Data/TopListData.swift index 05a2b46ed2c4..96659dbd150c 100644 --- a/Modules/Sources/JetpackStats/Services/Data/TopListData.swift +++ b/Modules/Sources/JetpackStats/Services/Data/TopListData.swift @@ -43,6 +43,7 @@ extension TopListData { let domain: String? let iconURL: URL? let isSpam: Bool? + let children: [Referrer] var metrics: SiteMetricsSet var id: TopListItemID { diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift index 1b12bb27a464..fd0319b34f38 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift @@ -92,6 +92,8 @@ private extension TopListItemView { return true case is TopListData.Author: return true + case is TopListData.Referrer: + return true default: return false } @@ -113,6 +115,11 @@ private extension TopListItemView { .environment(\.context, context) .environment(\.router, router) router.navigate(to: detailsView) + case let referrer as TopListData.Referrer: + let detailsView = ReferrerStatsView(referrer: referrer) + .environment(\.context, context) + .environment(\.router, router) + router.navigate(to: detailsView) default: break } From cdd6b2ccd4c68168b130a4ccb9c4943607e88445 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 25 Jul 2025 15:34:01 -0400 Subject: [PATCH 091/349] cardStyle more horizontal space --- .../JetpackStats/Utilities/Modifiers/CardModifier.swift | 2 +- .../Sources/JetpackStats/Views/TopList/TopListItemView.swift | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Modules/Sources/JetpackStats/Utilities/Modifiers/CardModifier.swift b/Modules/Sources/JetpackStats/Utilities/Modifiers/CardModifier.swift index 869839ab2446..4c7ee0f94ba0 100644 --- a/Modules/Sources/JetpackStats/Utilities/Modifiers/CardModifier.swift +++ b/Modules/Sources/JetpackStats/Utilities/Modifiers/CardModifier.swift @@ -9,7 +9,7 @@ struct CardModifier: ViewModifier { .stroke(Color(.opaqueSeparator), lineWidth: 0.5) ) .clipShape(RoundedRectangle(cornerRadius: 16)) - .padding(.horizontal, Constants.step1) + .padding(.horizontal, Constants.step2 / 2) } } diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift index fd0319b34f38..f39939c989ab 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift @@ -17,7 +17,7 @@ struct TopListItemView: View { } label: { content } - .buttonStyle(PlainButtonStyle()) + .buttonStyle(.plain) } else { content } @@ -53,7 +53,7 @@ struct TopListItemView: View { Spacer(minLength: 6) // Metrics view - ZStack { + ZStack(alignment: .trailing) { if previousItem != nil { // Reserve space to avoid junky animations when changing period Text("+4.8K (31.2%)") From bf6c01f2ce9a35acd00bd2c0f23faa2bc56ff848 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 25 Jul 2025 15:36:38 -0400 Subject: [PATCH 092/349] More spacing in TopListItemView --- Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift index f39939c989ab..d7914476b96f 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift @@ -68,6 +68,7 @@ struct TopListItemView: View { showChevron: hasDetails ) } + .padding(.trailing, -3) } .padding(.vertical, 7) .background( From 738c8bec317152cbb46c2db3820535a43f73fad0 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 25 Jul 2025 15:56:07 -0400 Subject: [PATCH 093/349] Update design --- .../Screens/ReferrerStatsView.swift | 184 +++++++----------- .../Services/Data/TopListData.swift | 2 +- Modules/Sources/JetpackStats/Strings.swift | 19 +- .../JetpackStats/Views/WeeklyTrendsView.swift | 10 - 4 files changed, 79 insertions(+), 136 deletions(-) diff --git a/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift b/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift index 8396b1962e6c..aea2206e999d 100644 --- a/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift +++ b/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift @@ -3,43 +3,38 @@ import WordPressUI struct ReferrerStatsView: View { let referrer: TopListData.Referrer - + + private let imageSize: CGFloat = 28 + + @Environment(\.context) private var context @Environment(\.router) private var router - + var body: some View { - ScrollView { - VStack(spacing: Constants.step2) { - headerCard - - if !referrer.children.isEmpty { - childrenCard - } + ScrollView { + VStack(spacing: Constants.step3) { + headerCard + if !referrer.children.isEmpty { + childrenCard } - .padding(.horizontal, Constants.step2) - .padding(.vertical, Constants.step1) } - .background(Color(.systemGroupedBackground)) - .navigationTitle(Strings.ReferrerStats.title) - .navigationBarTitleDisplayMode(.inline) + .padding(.vertical, Constants.step1) + } + .background(Constants.Colors.background) + .navigationTitle(Strings.ReferrerDetails.title) + .navigationBarTitleDisplayMode(.inline) } - + private var placeholderIcon: some View { Image(systemName: "link.circle.fill") .resizable() .aspectRatio(contentMode: .fit) .foregroundColor(.secondary.opacity(0.5)) } -} -// MARK: - Subviews - -private extension ReferrerStatsView { var headerCard: some View { - VStack(spacing: Constants.step1) { + VStack(spacing: Constants.step2) { referrerInfoRow - Divider() - markAsSpamButton } .padding(Constants.step2) @@ -68,21 +63,23 @@ private extension ReferrerStatsView { } placeholder: { placeholderIcon } - .frame(width: 48, height: 48) - .clipShape(RoundedRectangle(cornerRadius: 8)) + .frame(width: imageSize, height: imageSize) } else { placeholderIcon - .frame(width: 48, height: 48) + .frame(width: imageSize, height: imageSize) } } var referrerDetails: some View { - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: 2) { Text(referrer.name) .font(.headline) .foregroundColor(.primary) - if let domain = referrer.domain { + if let domain = referrer.domain, let url = URL(string: "https://\(domain)") { + Link(domain, destination: url) + .font(.subheadline) + } else if let domain = referrer.domain { Text(domain) .font(.subheadline) .foregroundColor(.secondary) @@ -93,111 +90,67 @@ private extension ReferrerStatsView { @ViewBuilder var viewsCount: some View { if let views = referrer.metrics.views { - VStack(alignment: .trailing, spacing: 4) { + VStack(alignment: .trailing, spacing: 0) { Text(StatsValueFormatter.formatNumber(views)) - .font(.title2.weight(.semibold)) + .font(Font.make(.recoleta, textStyle: .title2, weight: .medium)) .foregroundColor(.primary) Text(SiteMetric.views.localizedTitle) - .font(.caption) + .font(.footnote) .foregroundColor(.secondary) } } } - + + @ViewBuilder var markAsSpamButton: some View { - Button { - // TODO: Implement mark as spam functionality - } label: { - Label(Strings.ReferrerStats.markAsSpam, systemImage: "exclamationmark.triangle") - .font(.subheadline.weight(.medium)) - .foregroundColor(.red) - .frame(maxWidth: .infinity) - .padding(.vertical, 8) + if referrer.isSpam == true { + HStack { + Image(systemName: "checkmark.shield.fill") + .font(.subheadline) + Text(Strings.ReferrerDetails.markedAsSpam) + .font(.subheadline.weight(.medium)) + } + .foregroundColor(.secondary) + .frame(maxWidth: .infinity) + } else { + Button(role: .destructive) { + // TODO: Implement mark as spam functionality + } label: { + Label(Strings.ReferrerDetails.markAsSpam, systemImage: "exclamationmark.triangle") + .foregroundColor(.red) + .frame(maxWidth: .infinity) + } + .buttonStyle(.plain) } - .buttonStyle(.plain) } var childrenCard: some View { - VStack(alignment: .leading, spacing: 0) { - Text(Strings.ReferrerStats.referralSources) + VStack(alignment: .leading, spacing: Constants.step2) { + Text(Strings.ReferrerDetails.referralSources) .font(.headline) .foregroundColor(.primary) - .padding(.horizontal, Constants.step2) - .padding(.vertical, Constants.step1) - - Divider() - - ForEach(referrer.children, id: \.id) { child in - childRow(for: child) - } + + TopListItemsView( + data: childrenChartData, + itemLimit: referrer.children.count, + dateRange: context.calendar.makeDateRange(for: .thisYear) + ) + } + .padding(Constants.step2) .cardStyle() } - func childRow(for child: TopListData.Referrer) -> some View { - VStack(spacing: 0) { - HStack { - childInfo(for: child) - - Spacer() - - if let views = child.metrics.views { - Text(StatsValueFormatter.formatNumber(views)) - .font(.subheadline.weight(.medium)) - .foregroundColor(.secondary) - } - } - .padding(.horizontal, Constants.step2) - .padding(.vertical, Constants.step1) - - if child.id != referrer.children.last?.id { - Divider() - .padding(.leading, Constants.step2) - } - } - } - - func childInfo(for child: TopListData.Referrer) -> some View { - VStack(alignment: .leading, spacing: 4) { - Text(child.name) - .font(.callout) - .foregroundColor(.primary) - .lineLimit(2) - - if let domain = child.domain, let url = URL(string: "https://\(domain)") { - Button { - router.openURL(url) - } label: { - Text(domain) - .font(.caption) - .underline() - } - .buttonStyle(.plain) - } - } - } -} - -// MARK: - Strings - -private extension Strings { - enum ReferrerStats { - static let title = NSLocalizedString( - "stats.referrer.title", - value: "Referrer Details", - comment: "Title for the referrer details screen" - ) + private var childrenChartData: TopListChartData { + let maxValue = referrer.children + .compactMap { $0.metrics.views } + .max() ?? 1 - static let markAsSpam = NSLocalizedString( - "stats.referrer.markAsSpam", - value: "Mark as Spam", - comment: "Button to mark a referrer as spam" - ) - - static let referralSources = NSLocalizedString( - "stats.referrer.referralSources", - value: "Referral Sources", - comment: "Section title for the list of referral sources" + return TopListChartData( + item: .referrers, + metric: .views, + items: referrer.children, + maxValue: maxValue ) } } @@ -205,7 +158,10 @@ private extension Strings { // MARK: - Preview #Preview { - ReferrerStatsView(referrer: .mock) + NavigationView { + ReferrerStatsView(referrer: .mock) + } + .tint(Constants.Colors.blue) } private extension TopListData.Referrer { diff --git a/Modules/Sources/JetpackStats/Services/Data/TopListData.swift b/Modules/Sources/JetpackStats/Services/Data/TopListData.swift index 96659dbd150c..2d3a7d6b8f85 100644 --- a/Modules/Sources/JetpackStats/Services/Data/TopListData.swift +++ b/Modules/Sources/JetpackStats/Services/Data/TopListData.swift @@ -47,7 +47,7 @@ extension TopListData { var metrics: SiteMetricsSet var id: TopListItemID { - TopListItemID(type: .referrers, id: domain ?? name) + TopListItemID(type: .referrers, id: (domain ?? "–") + name) } } diff --git a/Modules/Sources/JetpackStats/Strings.swift b/Modules/Sources/JetpackStats/Strings.swift index 094a52cd8cae..deefbd73d91e 100644 --- a/Modules/Sources/JetpackStats/Strings.swift +++ b/Modules/Sources/JetpackStats/Strings.swift @@ -148,16 +148,6 @@ enum Strings { return String.localizedStringWithFormat(format, count) } - // Accessibility - static func weeklyActivityAccessibility(weeksCount: Int, metric: String, total: String) -> String { - String.localizedStringWithFormat( - AppLocalizedString("jetpackStats.postDetails.weeklyActivity.accessibility", - value: "Weekly activity heatmap showing %1$d weeks of %2$@ data. Total: %3$@", - comment: "VoiceOver description for weekly activity heatmap. %1$d is number of weeks, %2$@ is metric name, %3$@ is total value"), - weeksCount, metric, total - ) - } - // Tooltip static let weekTotal = AppLocalizedString("jetpackStats.postDetails.weekTotal", value: "Week Total", comment: "Label for weekly total in tooltip") static let dailyAverage = AppLocalizedString("jetpackStats.postDetails.dailyAverage", value: "Daily Average", comment: "Label for daily average in tooltip") @@ -165,6 +155,13 @@ enum Strings { } enum AuthorDetails { - static let title = AppLocalizedString("jetpackStats.authorDetails.title", value: "Author Stats", comment: "Title for the author details screen") + static let title = AppLocalizedString("jetpackStats.authorDetails.title", value: "Author", comment: "Title for the author details screen") + } + + enum ReferrerDetails { + static let title = AppLocalizedString("jetpackStats.referrerDetails.title", value: "Referrer", comment: "Title for the referrer details screen") + static let markAsSpam = AppLocalizedString("jetpackStats.referrerDetails.markAsSpam", value: "Mark as Spam", comment: "Button to mark a referrer as spam") + static let markedAsSpam = AppLocalizedString("jetpackStats.referrerDetails.markedAsSpam", value: "Marked as Spam", comment: "Label shown when a referrer is already marked as spam") + static let referralSources = AppLocalizedString("jetpackStats.referrerDetails.referralSources", value: "Referral Sources", comment: "Section title for the list of referral sources") } } diff --git a/Modules/Sources/JetpackStats/Views/WeeklyTrendsView.swift b/Modules/Sources/JetpackStats/Views/WeeklyTrendsView.swift index 7de5fe420e45..7749b274b55d 100644 --- a/Modules/Sources/JetpackStats/Views/WeeklyTrendsView.swift +++ b/Modules/Sources/JetpackStats/Views/WeeklyTrendsView.swift @@ -44,7 +44,6 @@ struct WeeklyTrendsView: View { } .frame(maxWidth: .infinity, alignment: .leading) .accessibilityElement(children: .combine) - .accessibilityLabel(accessibilityLabel) } private var header: some View { @@ -100,14 +99,6 @@ struct WeeklyTrendsView: View { private var legend: some View { HeatmapLegendView(metric: viewModel.metric, labelWidth: weekLabelWidth) } - - private var accessibilityLabel: String { - let weeksCount = min(viewModel.weeks.count, 4) - let totalValue = viewModel.weeks.prefix(4).flatMap { $0.days }.reduce(0) { $0 + $1.value } - let formattedTotal = viewModel.formatValue(totalValue) - - return Strings.PostDetails.weeklyActivityAccessibility(weeksCount: weeksCount, metric: viewModel.metric.localizedTitle, total: formattedTotal) - } } final class WeeklyTrendsViewModel: ObservableObject { @@ -273,7 +264,6 @@ private struct DayCell: View { .modifier(PopoverPresentationModifier()) } .accessibilityElement() - .accessibilityLabel(accessibilityLabel) .accessibilityAddTraits(.isButton) } From 2b98391786db9f8542c35a493cdf72a03124cc01 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 25 Jul 2025 16:01:03 -0400 Subject: [PATCH 094/349] Disable navigation for individual referrers --- Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift | 3 ++- .../Sources/JetpackStats/Views/TopList/TopListItemView.swift | 4 ++++ .../Sources/JetpackStats/Views/TopList/TopListItemsView.swift | 4 +++- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift b/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift index aea2206e999d..783f0dbc2d95 100644 --- a/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift +++ b/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift @@ -133,7 +133,8 @@ struct ReferrerStatsView: View { TopListItemsView( data: childrenChartData, itemLimit: referrer.children.count, - dateRange: context.calendar.makeDateRange(for: .thisYear) + dateRange: context.calendar.makeDateRange(for: .thisYear), // Not used + isNavigationDisabled: true ) } diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift index d7914476b96f..04c5d8c90116 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift @@ -6,6 +6,7 @@ struct TopListItemView: View { let metric: SiteMetric let maxValue: Int let dateRange: StatsDateRange + var isNavigationDisabled = false @Environment(\.router) private var router @Environment(\.context) private var context @@ -86,6 +87,9 @@ struct TopListItemView: View { private extension TopListItemView { var hasDetails: Bool { + guard !isNavigationDisabled else { + return false + } switch currentItem { case is TopListData.Post: return true diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift index 5e2a9e9d6fe6..f9a3577d5cdc 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift @@ -4,6 +4,7 @@ struct TopListItemsView: View { let data: TopListChartData let itemLimit: Int let dateRange: StatsDateRange + var isNavigationDisabled = false @State private var expandedSections: Set = [] @@ -60,7 +61,8 @@ struct TopListItemsView: View { previousItem: data.previousItem(for: item), metric: data.metric, maxValue: data.maxValue, - dateRange: dateRange + dateRange: dateRange, + isNavigationDisabled: isNavigationDisabled ) } From 4e2c1c0b058f97f84d9831764212a0d1207ed8b0 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 25 Jul 2025 16:05:20 -0400 Subject: [PATCH 095/349] Show date range --- .../Screens/ReferrerStatsView.swift | 24 ++++++++++++++++--- .../Views/TopList/TopListItemView.swift | 2 +- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift b/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift index 783f0dbc2d95..6eb5db2ebb00 100644 --- a/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift +++ b/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift @@ -3,6 +3,7 @@ import WordPressUI struct ReferrerStatsView: View { let referrer: TopListData.Referrer + let dateRange: StatsDateRange private let imageSize: CGFloat = 28 @@ -12,7 +13,10 @@ struct ReferrerStatsView: View { var body: some View { ScrollView { VStack(spacing: Constants.step3) { - headerCard + VStack(spacing: Constants.step1) { + headerCard + dateRangeLabel + } if !referrer.children.isEmpty { childrenCard } @@ -30,6 +34,17 @@ struct ReferrerStatsView: View { .aspectRatio(contentMode: .fit) .foregroundColor(.secondary.opacity(0.5)) } + + var dateRangeLabel: some View { + HStack { + Image(systemName: "calendar") + .font(.caption) + .foregroundColor(.secondary) + Text(context.formatters.dateRange.string(from: dateRange.dateInterval)) + .font(.caption) + .foregroundColor(.secondary) + } + } var headerCard: some View { VStack(spacing: Constants.step2) { @@ -133,7 +148,7 @@ struct ReferrerStatsView: View { TopListItemsView( data: childrenChartData, itemLimit: referrer.children.count, - dateRange: context.calendar.makeDateRange(for: .thisYear), // Not used + dateRange: dateRange, isNavigationDisabled: true ) @@ -160,7 +175,10 @@ struct ReferrerStatsView: View { #Preview { NavigationView { - ReferrerStatsView(referrer: .mock) + ReferrerStatsView( + referrer: .mock, + dateRange: Calendar.demo.makeDateRange(for: .thisYear) + ) } .tint(Constants.Colors.blue) } diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift index 04c5d8c90116..a5fcf96214c0 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift @@ -121,7 +121,7 @@ private extension TopListItemView { .environment(\.router, router) router.navigate(to: detailsView) case let referrer as TopListData.Referrer: - let detailsView = ReferrerStatsView(referrer: referrer) + let detailsView = ReferrerStatsView(referrer: referrer, dateRange: dateRange) .environment(\.context, context) .environment(\.router, router) router.navigate(to: detailsView) From 264fd78d4e195674d30ac45c759bdc5d59461d95 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 25 Jul 2025 16:07:41 -0400 Subject: [PATCH 096/349] Make domains tappable alwaus --- .../TopList/Rows/TopListReferrerRowView.swift | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListReferrerRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListReferrerRowView.swift index 2f249c38cc52..56d396d97fee 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListReferrerRowView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListReferrerRowView.swift @@ -29,10 +29,16 @@ struct TopListReferrerRowView: View { .lineLimit(1) if let domain = item.domain { - Text(domain) - .font(.caption) - .foregroundColor(.secondary) - .lineLimit(1) + if let url = URL(string: "https://\(domain)") { + Link(domain, destination: url) + .font(.caption) + .lineLimit(1) + } else { + Text(domain) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } } } } From c0dfd5500879084ee5a3b03ef44c75126351d95f Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 25 Jul 2025 16:08:06 -0400 Subject: [PATCH 097/349] SwiftLint --- .../Screens/ReferrerStatsView.swift | 24 +++++++++---------- .../TopList/Rows/TopListReferrerRowView.swift | 4 ++-- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift b/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift index 6eb5db2ebb00..9b8e6278b52c 100644 --- a/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift +++ b/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift @@ -34,7 +34,7 @@ struct ReferrerStatsView: View { .aspectRatio(contentMode: .fit) .foregroundColor(.secondary.opacity(0.5)) } - + var dateRangeLabel: some View { HStack { Image(systemName: "calendar") @@ -55,19 +55,19 @@ struct ReferrerStatsView: View { .padding(Constants.step2) .cardStyle() } - + var referrerInfoRow: some View { HStack(spacing: Constants.step1) { referrerIcon - + referrerDetails - + Spacer() - + viewsCount } } - + @ViewBuilder var referrerIcon: some View { if let iconURL = referrer.iconURL { @@ -84,13 +84,13 @@ struct ReferrerStatsView: View { .frame(width: imageSize, height: imageSize) } } - + var referrerDetails: some View { VStack(alignment: .leading, spacing: 2) { Text(referrer.name) .font(.headline) .foregroundColor(.primary) - + if let domain = referrer.domain, let url = URL(string: "https://\(domain)") { Link(domain, destination: url) .font(.subheadline) @@ -101,7 +101,7 @@ struct ReferrerStatsView: View { } } } - + @ViewBuilder var viewsCount: some View { if let views = referrer.metrics.views { @@ -138,7 +138,7 @@ struct ReferrerStatsView: View { .buttonStyle(.plain) } } - + var childrenCard: some View { VStack(alignment: .leading, spacing: Constants.step2) { Text(Strings.ReferrerDetails.referralSources) @@ -156,12 +156,12 @@ struct ReferrerStatsView: View { .padding(Constants.step2) .cardStyle() } - + private var childrenChartData: TopListChartData { let maxValue = referrer.children .compactMap { $0.metrics.views } .max() ?? 1 - + return TopListChartData( item: .referrers, metric: .views, diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListReferrerRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListReferrerRowView.swift index 56d396d97fee..d4054803d941 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListReferrerRowView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListReferrerRowView.swift @@ -21,7 +21,7 @@ struct TopListReferrerRowView: View { placeholderIcon .frame(width: 24, height: 24) } - + VStack(alignment: .leading, spacing: 2) { Text(item.name) .font(.callout) @@ -43,7 +43,7 @@ struct TopListReferrerRowView: View { } } } - + private var placeholderIcon: some View { Image(systemName: "link.circle.fill") .resizable() From dcd6df368307f0ebb1d724559ef929f1a0179202 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 25 Jul 2025 16:17:12 -0400 Subject: [PATCH 098/349] Use blue color for links --- Modules/Sources/JetpackStats/Constants.swift | 2 ++ Modules/Sources/JetpackStats/Screens/PostStatsView.swift | 2 +- Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift | 3 ++- .../Views/TopList/Rows/TopListReferrerRowView.swift | 1 + 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Modules/Sources/JetpackStats/Constants.swift b/Modules/Sources/JetpackStats/Constants.swift index 83e956acd930..f79949c36bb3 100644 --- a/Modules/Sources/JetpackStats/Constants.swift +++ b/Modules/Sources/JetpackStats/Constants.swift @@ -35,6 +35,8 @@ enum Constants { static let orange = Color(palette: CSColor.Orange.self) static let pink = Color(palette: CSColor.Pink.self) static let celadon = Color(palette: CSColor.Celadon.self) + + static let jetpack = Color(palette: CSColor.JetpackGreen.self) } static let step1: CGFloat = 12 diff --git a/Modules/Sources/JetpackStats/Screens/PostStatsView.swift b/Modules/Sources/JetpackStats/Screens/PostStatsView.swift index 1a07973deb84..e0e027703635 100644 --- a/Modules/Sources/JetpackStats/Screens/PostStatsView.swift +++ b/Modules/Sources/JetpackStats/Screens/PostStatsView.swift @@ -165,7 +165,7 @@ public struct PostStatsView: View { Link(destination: postURL) { Image(systemName: "link") .font(.footnote) - .foregroundColor(.accentColor) + .foregroundColor(Constants.Colors.blue) } } } diff --git a/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift b/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift index 9b8e6278b52c..c39480cab936 100644 --- a/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift +++ b/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift @@ -94,6 +94,7 @@ struct ReferrerStatsView: View { if let domain = referrer.domain, let url = URL(string: "https://\(domain)") { Link(domain, destination: url) .font(.subheadline) + .tint(Constants.Colors.blue) } else if let domain = referrer.domain { Text(domain) .font(.subheadline) @@ -180,7 +181,7 @@ struct ReferrerStatsView: View { dateRange: Calendar.demo.makeDateRange(for: .thisYear) ) } - .tint(Constants.Colors.blue) + .tint(Constants.Colors.jetpack) } private extension TopListData.Referrer { diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListReferrerRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListReferrerRowView.swift index d4054803d941..d769c80efeec 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListReferrerRowView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListReferrerRowView.swift @@ -32,6 +32,7 @@ struct TopListReferrerRowView: View { if let url = URL(string: "https://\(domain)") { Link(domain, destination: url) .font(.caption) + .tint(Constants.Colors.blue) .lineLimit(1) } else { Text(domain) From 5aab52ace178d2b6c561e1163185e021183ce017 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 25 Jul 2025 17:52:36 -0400 Subject: [PATCH 099/349] Implement mark as spam --- .../Screens/ReferrerStatsView.swift | 50 ++++++++++++------- .../Services/Data/TopListData.swift | 2 +- .../Services/Mocks/MockStatsService.swift | 11 ++++ .../JetpackStats/Services/StatsService.swift | 4 ++ .../Services/StatsServiceProtocol.swift | 1 + Modules/Sources/JetpackStats/Strings.swift | 3 ++ 6 files changed, 53 insertions(+), 18 deletions(-) diff --git a/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift b/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift index c39480cab936..91037ce166a4 100644 --- a/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift +++ b/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift @@ -2,21 +2,21 @@ import SwiftUI import WordPressUI struct ReferrerStatsView: View { - let referrer: TopListData.Referrer + @State var referrer: TopListData.Referrer let dateRange: StatsDateRange private let imageSize: CGFloat = 28 @Environment(\.context) private var context @Environment(\.router) private var router + @State private var isMarkingAsSpam = false + @State private var showErrorAlert = false + @State private var errorMessage = "" var body: some View { ScrollView { VStack(spacing: Constants.step3) { - VStack(spacing: Constants.step1) { - headerCard - dateRangeLabel - } + headerCard if !referrer.children.isEmpty { childrenCard } @@ -26,6 +26,11 @@ struct ReferrerStatsView: View { .background(Constants.Colors.background) .navigationTitle(Strings.ReferrerDetails.title) .navigationBarTitleDisplayMode(.inline) + .alert(Strings.ReferrerDetails.errorAlertTitle, isPresented: $showErrorAlert) { + Button(Strings.Buttons.ok, role: .cancel) { } + } message: { + Text(errorMessage) + } } private var placeholderIcon: some View { @@ -35,17 +40,6 @@ struct ReferrerStatsView: View { .foregroundColor(.secondary.opacity(0.5)) } - var dateRangeLabel: some View { - HStack { - Image(systemName: "calendar") - .font(.caption) - .foregroundColor(.secondary) - Text(context.formatters.dateRange.string(from: dateRange.dateInterval)) - .font(.caption) - .foregroundColor(.secondary) - } - } - var headerCard: some View { VStack(spacing: Constants.step2) { referrerInfoRow @@ -128,9 +122,14 @@ struct ReferrerStatsView: View { } .foregroundColor(.secondary) .frame(maxWidth: .infinity) + } else if isMarkingAsSpam { + ProgressView() + .frame(maxWidth: .infinity) } else { Button(role: .destructive) { - // TODO: Implement mark as spam functionality + Task { + await markAsSpam() + } } label: { Label(Strings.ReferrerDetails.markAsSpam, systemImage: "exclamationmark.triangle") .foregroundColor(.red) @@ -170,6 +169,23 @@ struct ReferrerStatsView: View { maxValue: maxValue ) } + + private func markAsSpam() async { + guard let domain = referrer.domain else { return } + + isMarkingAsSpam = true + + do { + try await context.service.toggleSpamState(for: domain, currentValue: referrer.isSpam ?? false) + // Update local state to reflect the change + referrer.isSpam = true + } catch { + errorMessage = error.localizedDescription.isEmpty ? Strings.ReferrerDetails.markAsSpamError : error.localizedDescription + showErrorAlert = true + } + + isMarkingAsSpam = false + } } // MARK: - Preview diff --git a/Modules/Sources/JetpackStats/Services/Data/TopListData.swift b/Modules/Sources/JetpackStats/Services/Data/TopListData.swift index 2d3a7d6b8f85..64d685c909e3 100644 --- a/Modules/Sources/JetpackStats/Services/Data/TopListData.swift +++ b/Modules/Sources/JetpackStats/Services/Data/TopListData.swift @@ -42,7 +42,7 @@ extension TopListData { let name: String let domain: String? let iconURL: URL? - let isSpam: Bool? + var isSpam: Bool? let children: [Referrer] var metrics: SiteMetricsSet diff --git a/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift b/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift index 214c939dae13..94010f173cc9 100644 --- a/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift +++ b/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift @@ -302,6 +302,17 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { return PostLikesData(users: selectedUsers, totalCount: 26) } + func toggleSpamState(for referrerDomain: String, currentValue: Bool) async throws { + // Simulate network delay + try? await Task.sleep(for: .milliseconds(Int.random(in: 200...500))) + + // Mock implementation - randomly succeed or fail for testing + let shouldSucceed = Double.random(in: 0...1) > 0.1 // 90% success rate + if !shouldSucceed { + throw URLError(.networkConnectionLost) + } + } + // MARK: - Data Loading /// Loads historical items from JSON files based on the data type diff --git a/Modules/Sources/JetpackStats/Services/StatsService.swift b/Modules/Sources/JetpackStats/Services/StatsService.swift index f758a7c209a8..7e19b9ee2cb7 100644 --- a/Modules/Sources/JetpackStats/Services/StatsService.swift +++ b/Modules/Sources/JetpackStats/Services/StatsService.swift @@ -247,6 +247,10 @@ actor StatsService: StatsServiceProtocol { return result } + func toggleSpamState(for referrerDomain: String, currentValue: Bool) async throws { + try await service.toggleSpamState(for: referrerDomain, currentValue: currentValue) + } + // MARK: - Dates /// Convert from the site timezone (used in JetpackState) to the local diff --git a/Modules/Sources/JetpackStats/Services/StatsServiceProtocol.swift b/Modules/Sources/JetpackStats/Services/StatsServiceProtocol.swift index abdb84fb2f88..4a2663e9e247 100644 --- a/Modules/Sources/JetpackStats/Services/StatsServiceProtocol.swift +++ b/Modules/Sources/JetpackStats/Services/StatsServiceProtocol.swift @@ -12,4 +12,5 @@ protocol StatsServiceProtocol: AnyObject, Sendable { 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 + func toggleSpamState(for referrerDomain: String, currentValue: Bool) async throws } diff --git a/Modules/Sources/JetpackStats/Strings.swift b/Modules/Sources/JetpackStats/Strings.swift index deefbd73d91e..556e162ddbe9 100644 --- a/Modules/Sources/JetpackStats/Strings.swift +++ b/Modules/Sources/JetpackStats/Strings.swift @@ -63,6 +63,7 @@ enum Strings { static let apply = AppLocalizedString("jetpackStats.button.apply", value: "Apply", comment: "Apply button") static let share = AppLocalizedString("jetpackStats.button.share", value: "Share", comment: "Share chart menu item") static let showAll = AppLocalizedString("jetpackStats.button.showAll", value: "Show All", comment: "Button title") + static let ok = AppLocalizedString("jetpackStats.button.ok", value: "OK", comment: "OK button") } enum DatePicker { @@ -163,5 +164,7 @@ enum Strings { static let markAsSpam = AppLocalizedString("jetpackStats.referrerDetails.markAsSpam", value: "Mark as Spam", comment: "Button to mark a referrer as spam") static let markedAsSpam = AppLocalizedString("jetpackStats.referrerDetails.markedAsSpam", value: "Marked as Spam", comment: "Label shown when a referrer is already marked as spam") static let referralSources = AppLocalizedString("jetpackStats.referrerDetails.referralSources", value: "Referral Sources", comment: "Section title for the list of referral sources") + static let markAsSpamError = AppLocalizedString("jetpackStats.referrerDetails.markAsSpamError", value: "Failed to mark as spam", comment: "Error message when marking a referrer as spam fails") + static let errorAlertTitle = AppLocalizedString("jetpackStats.referrerDetails.errorAlertTitle", value: "Error", comment: "Title for error alert when marking referrer as spam fails") } } From 8b3ace757591237d1820332ad727ac92eac6c763 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 25 Jul 2025 17:56:38 -0400 Subject: [PATCH 100/349] new referrer details page --- .../JetpackStats/Screens/ReferrerStatsView.swift | 5 +---- .../TopList/Rows/TopListReferrerRowView.swift | 15 ++++++++------- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift b/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift index 91037ce166a4..0ab48b18702b 100644 --- a/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift +++ b/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift @@ -53,11 +53,8 @@ struct ReferrerStatsView: View { var referrerInfoRow: some View { HStack(spacing: Constants.step1) { referrerIcon - referrerDetails - Spacer() - viewsCount } } @@ -154,7 +151,7 @@ struct ReferrerStatsView: View { } .padding(Constants.step2) - .cardStyle() +// .cardStyle() } private var childrenChartData: TopListChartData { diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListReferrerRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListReferrerRowView.swift index d769c80efeec..57b392a2e0f2 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListReferrerRowView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListReferrerRowView.swift @@ -28,19 +28,20 @@ struct TopListReferrerRowView: View { .foregroundColor(.primary) .lineLimit(1) - if let domain = item.domain { - if let url = URL(string: "https://\(domain)") { - Link(domain, destination: url) + HStack { + if let domain = item.domain { + Text(verbatim: domain) .font(.caption) - .tint(Constants.Colors.blue) - .lineLimit(1) - } else { - Text(domain) + } + if !item.children.isEmpty { + Text(verbatim: "+\(item.children)") .font(.caption) .foregroundColor(.secondary) .lineLimit(1) } } + .foregroundColor(.secondary) + .lineLimit(1) } } } From c19d4bc740a5c22d22732973c2e051a783e2ef15 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 25 Jul 2025 18:02:22 -0400 Subject: [PATCH 101/349] Cleanup --- Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift | 1 - .../Views/TopList/Rows/TopListReferrerRowView.swift | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift b/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift index 0ab48b18702b..bbef7246df7b 100644 --- a/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift +++ b/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift @@ -151,7 +151,6 @@ struct ReferrerStatsView: View { } .padding(Constants.step2) -// .cardStyle() } private var childrenChartData: TopListChartData { diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListReferrerRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListReferrerRowView.swift index 57b392a2e0f2..b78057501829 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListReferrerRowView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListReferrerRowView.swift @@ -28,13 +28,13 @@ struct TopListReferrerRowView: View { .foregroundColor(.primary) .lineLimit(1) - HStack { + HStack(spacing: 0) { if let domain = item.domain { Text(verbatim: domain) .font(.caption) } if !item.children.isEmpty { - Text(verbatim: "+\(item.children)") + Text(verbatim: "…") .font(.caption) .foregroundColor(.secondary) .lineLimit(1) From b30fe395fb4646e1f11e7187bc4d3499b4098655 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 25 Jul 2025 18:06:00 -0400 Subject: [PATCH 102/349] Fix tap area for TopListItemView --- .../Views/TopList/Rows/TopListReferrerRowView.swift | 5 ++--- .../Sources/JetpackStats/Views/TopList/TopListItemView.swift | 1 + 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListReferrerRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListReferrerRowView.swift index b78057501829..f4d80986f9e3 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListReferrerRowView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListReferrerRowView.swift @@ -34,10 +34,9 @@ struct TopListReferrerRowView: View { .font(.caption) } if !item.children.isEmpty { - Text(verbatim: "…") + let prefix = item.domain == nil ? "" : "," + Text(verbatim: "\(prefix) +\(item.children.count - 1)") .font(.caption) - .foregroundColor(.secondary) - .lineLimit(1) } } .foregroundColor(.secondary) diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift index a5fcf96214c0..a77b4af4b474 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift @@ -17,6 +17,7 @@ struct TopListItemView: View { navigateToDetails() } label: { content + .contentShape(Rectangle()) // Make the entire view tappable } .buttonStyle(.plain) } else { From b51968fd7c196af4ac490ddd2caf3905bcf8046f Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 25 Jul 2025 18:34:16 -0400 Subject: [PATCH 103/349] Remove isSpam --- .../JetpackStats/Cards/TopListCard.swift | 3 ++- .../Screens/ReferrerStatsView.swift | 21 ++++++++----------- .../Services/Data/TopListChartData.swift | 4 ---- .../Data/TopListData+WordPressKit.swift | 1 - .../Services/Data/TopListData.swift | 1 - .../Services/Mocks/MockStatsService.swift | 2 +- 6 files changed, 12 insertions(+), 20 deletions(-) diff --git a/Modules/Sources/JetpackStats/Cards/TopListCard.swift b/Modules/Sources/JetpackStats/Cards/TopListCard.swift index f84e2842d27f..4e9f267e7610 100644 --- a/Modules/Sources/JetpackStats/Cards/TopListCard.swift +++ b/Modules/Sources/JetpackStats/Cards/TopListCard.swift @@ -195,5 +195,6 @@ struct TopListCard: View { service: MockStatsService() )) .cardStyle() - .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Constants.Colors.background) } diff --git a/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift b/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift index bbef7246df7b..865c9306d029 100644 --- a/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift +++ b/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift @@ -2,7 +2,7 @@ import SwiftUI import WordPressUI struct ReferrerStatsView: View { - @State var referrer: TopListData.Referrer + let referrer: TopListData.Referrer let dateRange: StatsDateRange private let imageSize: CGFloat = 28 @@ -12,6 +12,7 @@ struct ReferrerStatsView: View { @State private var isMarkingAsSpam = false @State private var showErrorAlert = false @State private var errorMessage = "" + @State private var isMarkedAsSpam = false var body: some View { ScrollView { @@ -110,7 +111,7 @@ struct ReferrerStatsView: View { @ViewBuilder var markAsSpamButton: some View { - if referrer.isSpam == true { + if isMarkedAsSpam { HStack { Image(systemName: "checkmark.shield.fill") .font(.subheadline) @@ -165,21 +166,21 @@ struct ReferrerStatsView: View { maxValue: maxValue ) } - + private func markAsSpam() async { guard let domain = referrer.domain else { return } - + isMarkingAsSpam = true - + do { - try await context.service.toggleSpamState(for: domain, currentValue: referrer.isSpam ?? false) + try await context.service.toggleSpamState(for: domain, currentValue: isMarkedAsSpam) // Update local state to reflect the change - referrer.isSpam = true + isMarkedAsSpam = true } catch { errorMessage = error.localizedDescription.isEmpty ? Strings.ReferrerDetails.markAsSpamError : error.localizedDescription showErrorAlert = true } - + isMarkingAsSpam = false } } @@ -201,13 +202,11 @@ private extension TopListData.Referrer { name: "Google Search", domain: "google.com", iconURL: URL(string: "https://www.google.com/favicon.ico"), - isSpam: false, children: [ TopListData.Referrer( name: "wordpress development tutorial", domain: "google.com", iconURL: URL(string: "https://www.google.com/favicon.ico"), - isSpam: false, children: [], metrics: SiteMetricsSet(views: 850) ), @@ -215,7 +214,6 @@ private extension TopListData.Referrer { name: "swift programming blog", domain: "google.com", iconURL: URL(string: "https://www.google.com/favicon.ico"), - isSpam: false, children: [], metrics: SiteMetricsSet(views: 750) ), @@ -223,7 +221,6 @@ private extension TopListData.Referrer { name: "ios app development best practices", domain: "google.com", iconURL: URL(string: "https://www.google.com/favicon.ico"), - isSpam: false, children: [], metrics: SiteMetricsSet(views: 600) ) diff --git a/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift b/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift index 59df3410a2f8..ec34b380b6f7 100644 --- a/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift +++ b/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift @@ -132,13 +132,11 @@ extension TopListChartData { name: data.0, domain: data.1, iconURL: nil, - isSpam: data.0 == "Facebook", children: [ TopListData.Referrer( name: "wordpress development tutorial", domain: "google.com", iconURL: URL(string: "https://www.google.com/favicon.ico"), - isSpam: false, children: [], metrics: SiteMetricsSet(views: 850) ), @@ -146,7 +144,6 @@ extension TopListChartData { name: "swift programming blog", domain: "google.com", iconURL: URL(string: "https://www.google.com/favicon.ico"), - isSpam: false, children: [], metrics: SiteMetricsSet(views: 750) ), @@ -154,7 +151,6 @@ extension TopListChartData { name: "ios app development best practices", domain: "google.com", iconURL: URL(string: "https://www.google.com/favicon.ico"), - isSpam: false, children: [], metrics: SiteMetricsSet(views: 600) ) diff --git a/Modules/Sources/JetpackStats/Services/Data/TopListData+WordPressKit.swift b/Modules/Sources/JetpackStats/Services/Data/TopListData+WordPressKit.swift index 26e0a990bf6e..350909e62dc3 100644 --- a/Modules/Sources/JetpackStats/Services/Data/TopListData+WordPressKit.swift +++ b/Modules/Sources/JetpackStats/Services/Data/TopListData+WordPressKit.swift @@ -21,7 +21,6 @@ extension TopListData.Referrer { name: referrer.title, domain: referrer.url?.host, iconURL: referrer.iconURL, - isSpam: referrer.isSpam, children: referrer.children.map { TopListData.Referrer($0) }, metrics: SiteMetricsSet(views: referrer.viewsCount) ) diff --git a/Modules/Sources/JetpackStats/Services/Data/TopListData.swift b/Modules/Sources/JetpackStats/Services/Data/TopListData.swift index 64d685c909e3..bc67742eae2c 100644 --- a/Modules/Sources/JetpackStats/Services/Data/TopListData.swift +++ b/Modules/Sources/JetpackStats/Services/Data/TopListData.swift @@ -42,7 +42,6 @@ extension TopListData { let name: String let domain: String? let iconURL: URL? - var isSpam: Bool? let children: [Referrer] var metrics: SiteMetricsSet diff --git a/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift b/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift index 94010f173cc9..780823de6707 100644 --- a/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift +++ b/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift @@ -305,7 +305,7 @@ actor MockStatsService: ObservableObject, StatsServiceProtocol { func toggleSpamState(for referrerDomain: String, currentValue: Bool) async throws { // Simulate network delay try? await Task.sleep(for: .milliseconds(Int.random(in: 200...500))) - + // Mock implementation - randomly succeed or fail for testing let shouldSucceed = Double.random(in: 0...1) > 0.1 // 90% success rate if !shouldSucceed { From 1ada0afe06674229fbab331c9b9f05af7536dc7d Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 25 Jul 2025 18:45:35 -0400 Subject: [PATCH 104/349] Fix insets in StatsTabBar --- Modules/Sources/JetpackStats/Views/StatsTabBar.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/JetpackStats/Views/StatsTabBar.swift b/Modules/Sources/JetpackStats/Views/StatsTabBar.swift index dc56e0ecfe82..adf77a93f014 100644 --- a/Modules/Sources/JetpackStats/Views/StatsTabBar.swift +++ b/Modules/Sources/JetpackStats/Views/StatsTabBar.swift @@ -28,7 +28,7 @@ struct StatsTabBar: View { tabButton(for: tab) } } - .padding(.horizontal, 32) + .padding(.horizontal, 27) } Divider() } From a1f2987910510072f6ca5c03b8e8d294a3ea74c9 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 25 Jul 2025 19:01:23 -0400 Subject: [PATCH 105/349] Fix --- .../Views/TopList/Rows/TopListReferrerRowView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListReferrerRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListReferrerRowView.swift index f4d80986f9e3..cc295e15cb0d 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListReferrerRowView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListReferrerRowView.swift @@ -35,7 +35,7 @@ struct TopListReferrerRowView: View { } if !item.children.isEmpty { let prefix = item.domain == nil ? "" : "," - Text(verbatim: "\(prefix) +\(item.children.count - 1)") + Text(verbatim: "\(prefix) +\(item.children.count)") .font(.caption) } } From 9c886ab4879c7bde4fe36768b50a72f601a22850 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Fri, 25 Jul 2025 19:07:52 -0400 Subject: [PATCH 106/349] Fix preconcurrency warnings --- Modules/Sources/JetpackStats/Screens/PostStatsView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/JetpackStats/Screens/PostStatsView.swift b/Modules/Sources/JetpackStats/Screens/PostStatsView.swift index e0e027703635..82620bb7dbcc 100644 --- a/Modules/Sources/JetpackStats/Screens/PostStatsView.swift +++ b/Modules/Sources/JetpackStats/Screens/PostStatsView.swift @@ -1,6 +1,6 @@ import SwiftUI import UIKit -import WordPressKit +@preconcurrency import WordPressKit public struct PostStatsView: View { public struct PostInfo { From 508298fc0f4ed33c7826611812f25af370ab02d2 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Sat, 26 Jul 2025 09:45:29 -0400 Subject: [PATCH 107/349] Add initial country map implementation --- Modules/Package.swift | 1 + .../JetpackStats/Cards/TopListCard.swift | 8 +- .../JetpackStats/Resources/world-map.svg | 809 ++++++++++++++++++ .../JetpackStats/Views/CountriesMapView.swift | 160 ++++ 4 files changed, 977 insertions(+), 1 deletion(-) create mode 100644 Modules/Sources/JetpackStats/Resources/world-map.svg create mode 100644 Modules/Sources/JetpackStats/Views/CountriesMapView.swift diff --git a/Modules/Package.swift b/Modules/Package.swift index 7efc5411c3df..c21c87bba187 100644 --- a/Modules/Package.swift +++ b/Modules/Package.swift @@ -99,6 +99,7 @@ let package = Package( dependencies: [ "WordPressUI", .product(name: "WordPressKit", package: "WordPressKit-iOS"), + .product(name: "FSInteractiveMap", package: "FSInteractiveMap"), ], resources: [.process("Resources")] ), diff --git a/Modules/Sources/JetpackStats/Cards/TopListCard.swift b/Modules/Sources/JetpackStats/Cards/TopListCard.swift index 4e9f267e7610..dd36ece2102d 100644 --- a/Modules/Sources/JetpackStats/Cards/TopListCard.swift +++ b/Modules/Sources/JetpackStats/Cards/TopListCard.swift @@ -14,10 +14,16 @@ struct TopListCard: View { var body: some View { VStack(alignment: .leading, spacing: 16) { HStack { - StatsCardTitleView(title: viewModel.title) + StatsCardTitleView(title: viewModel.selection.item == .locations ? "Countries" : viewModel.title) Spacer(minLength: 44) } VStack(spacing: 12) { + if viewModel.selection.item == .locations, let data = viewModel.matchedData, !data.items.isEmpty { + CountriesMapContainer( + data: CountriesMapData(locations: data.items.compactMap { $0 as? TopListData.Location }), + primaryColor: Constants.Colors.blue + ) + } headerView contentView } diff --git a/Modules/Sources/JetpackStats/Resources/world-map.svg b/Modules/Sources/JetpackStats/Resources/world-map.svg new file mode 100644 index 000000000000..6daf01bb4084 --- /dev/null +++ b/Modules/Sources/JetpackStats/Resources/world-map.svg @@ -0,0 +1,809 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Modules/Sources/JetpackStats/Views/CountriesMapView.swift b/Modules/Sources/JetpackStats/Views/CountriesMapView.swift new file mode 100644 index 000000000000..2c3060eca566 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/CountriesMapView.swift @@ -0,0 +1,160 @@ +import SwiftUI +import FSInteractiveMap + +struct CountriesMapView: UIViewRepresentable { + let data: CountriesMapData + let primaryColor: UIColor + + func makeUIView(context: Context) -> FSInteractiveMapView { + let mapView = FSInteractiveMapView(frame: .zero) + mapView.backgroundColor = UIColor.secondarySystemGroupedBackground + return mapView + } + + func updateUIView(_ mapView: FSInteractiveMapView, context: Context) { + // Set basic map colors + mapView.strokeColor = .secondarySystemGroupedBackground + mapView.fillColor = UIColor(light: .systemGray5, dark: .systemGray6) + + // Load map with data and color axis + let colors = [ + primaryColor.withAlphaComponent(0.1), + primaryColor + ] + mapView.loadMap("world-map", withData: data.mapData, colorAxis: colors) + } +} + +struct CountriesMapData { + let minViewsCount: Int + let maxViewsCount: Int + let mapData: [String: NSNumber] + + init(locations: [TopListData.Location]) { + let sortedLocations = locations.sorted { ($0.metrics.views ?? 0) > ($1.metrics.views ?? 0) } + + self.minViewsCount = sortedLocations.last?.metrics.views ?? 0 + self.maxViewsCount = sortedLocations.first?.metrics.views ?? 0 + + self.mapData = locations.reduce(into: [String: NSNumber]()) { result, location in + if let countryCode = location.countryCode, + let views = location.metrics.views { + result[countryCode] = NSNumber(value: views) + } + } + } +} + +struct CountriesMapContainer: View { + let data: CountriesMapData + let primaryColor: Color + + var body: some View { + VStack(spacing: 12) { + // Map View + CountriesMapView(data: data, primaryColor: UIColor(primaryColor)) + .frame(height: 224) + .cornerRadius(8) + + // Gradient Legend + HStack(spacing: 0) { + Text(data.minViewsCount.abbreviatedString()) + .font(.footnote) + .foregroundColor(.secondary) + + Spacer() + + LinearGradient( + colors: [primaryColor.opacity(0.1), primaryColor], + startPoint: .leading, + endPoint: .trailing + ) + .frame(height: 10) + .cornerRadius(5) + + Spacer() + + Text(data.maxViewsCount.abbreviatedString()) + .font(.footnote) + .foregroundColor(.secondary) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("Views range from \(data.minViewsCount) to \(data.maxViewsCount)") + } + .accessibilityElement(children: .combine) + .accessibilityLabel("World map showing views by country") + } +} + +private extension Int { + func abbreviatedString() -> String { + let formatter = NumberFormatter() + formatter.maximumFractionDigits = 1 + + if self >= 1_000_000 { + return "\(formatter.string(from: NSNumber(value: Double(self) / 1_000_000)) ?? "0")M" + } else if self >= 1_000 { + return "\(formatter.string(from: NSNumber(value: Double(self) / 1_000)) ?? "0")K" + } else { + return "\(self)" + } + } +} + +#Preview { + CountriesMapContainer( + data: CountriesMapData(locations: [ + TopListData.Location( + country: "United States", + flag: "🇺🇸", + countryCode: "US", + metrics: SiteMetricsSet(views: 10000) + ), + TopListData.Location( + country: "United Kingdom", + flag: "🇬🇧", + countryCode: "GB", + metrics: SiteMetricsSet(views: 4000) + ), + TopListData.Location( + country: "Canada", + flag: "🇨🇦", + countryCode: "CA", + metrics: SiteMetricsSet(views: 2800) + ), + TopListData.Location( + country: "Germany", + flag: "🇩🇪", + countryCode: "DE", + metrics: SiteMetricsSet(views: 2000) + ), + TopListData.Location( + country: "Australia", + flag: "🇦🇺", + countryCode: "AU", + metrics: SiteMetricsSet(views: 1600) + ), + TopListData.Location( + country: "France", + flag: "🇫🇷", + countryCode: "FR", + metrics: SiteMetricsSet(views: 1400) + ), + TopListData.Location( + country: "Japan", + flag: "🇯🇵", + countryCode: "JP", + metrics: SiteMetricsSet(views: 1100) + ), + TopListData.Location( + country: "Netherlands", + flag: "🇳🇱", + countryCode: "NL", + metrics: SiteMetricsSet(views: 800) + ) + ]), + primaryColor: Constants.Colors.blue + ) + .padding() + .background(Color(UIColor.systemGroupedBackground)) +} From 19e9cc468808c65b62280ebbd0d3371dfe4ad2fe Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Sat, 26 Jul 2025 09:45:46 -0400 Subject: [PATCH 108/349] Make design more iOS 26 like with larger spacing and corner radius --- Modules/Sources/JetpackStats/Cards/ChartCard.swift | 12 ++++++++---- Modules/Sources/JetpackStats/Cards/TopListCard.swift | 3 ++- Modules/Sources/JetpackStats/Constants.swift | 2 +- .../JetpackStats/Services/Data/SiteMetric.swift | 2 +- .../JetpackStats/Services/Data/TopListItemType.swift | 2 +- .../Utilities/Modifiers/CardModifier.swift | 6 +++--- .../JetpackStats/Views/BadgeTrendIndicator.swift | 2 +- Modules/Sources/JetpackStats/Views/HeatmapView.swift | 4 ++-- .../JetpackStats/Views/MetricsOverviewTabView.swift | 7 ++++--- .../JetpackStats/Views/StatsCardTitleView.swift | 1 + .../Views/TopList/TopListItemBarBackground.swift | 2 +- .../JetpackStats/Views/TopList/TopListItemView.swift | 2 +- 12 files changed, 26 insertions(+), 19 deletions(-) diff --git a/Modules/Sources/JetpackStats/Cards/ChartCard.swift b/Modules/Sources/JetpackStats/Cards/ChartCard.swift index 93a09abdb833..964bcdd1acbd 100644 --- a/Modules/Sources/JetpackStats/Cards/ChartCard.swift +++ b/Modules/Sources/JetpackStats/Cards/ChartCard.swift @@ -21,12 +21,13 @@ struct ChartCard: View { var body: some View { VStack(spacing: 0) { - VStack(spacing: 6) { + VStack(spacing: Constants.step1) { headerView(for: selectedMetric) .unredacted() contentView } - .padding(Constants.step2) + .padding(.vertical, Constants.step2) + .padding(.horizontal, Constants.step3) if metrics.count > 1 { Divider() @@ -52,7 +53,7 @@ struct ChartCard: View { @ViewBuilder private var contentView: some View { - VStack(spacing: 14) { + VStack(spacing: Constants.step1 / 2) { // Showing currently selected (not loaded period) by design ChartLegendView( metric: selectedMetric, @@ -155,7 +156,7 @@ struct ChartCard: View { @ViewBuilder private func mainChartView(metric: SiteMetric, data: ChartData) -> some View { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: Constants.step1 / 2) { ChartValuesSummaryView( trend: TrendViewModel.make(data, context: .regular), style: metrics.count > 1 ? .compact : .standard @@ -163,6 +164,9 @@ struct ChartCard: View { chartContentView(data: data) .frame(height: chartHeight) .transition(.push(from: .trailing).combined(with: .opacity).combined(with: .scale)) + .overlay(alignment: .topLeading) { + + } } } diff --git a/Modules/Sources/JetpackStats/Cards/TopListCard.swift b/Modules/Sources/JetpackStats/Cards/TopListCard.swift index dd36ece2102d..3bae685f9b0a 100644 --- a/Modules/Sources/JetpackStats/Cards/TopListCard.swift +++ b/Modules/Sources/JetpackStats/Cards/TopListCard.swift @@ -31,7 +31,8 @@ struct TopListCard: View { .onAppear { viewModel.onAppear() } - .padding(Constants.step2) + .padding(.vertical, Constants.step2) + .padding(.horizontal, Constants.step3) .overlay(alignment: .topTrailing) { moreMenu } diff --git a/Modules/Sources/JetpackStats/Constants.swift b/Modules/Sources/JetpackStats/Constants.swift index f79949c36bb3..11372ac1e166 100644 --- a/Modules/Sources/JetpackStats/Constants.swift +++ b/Modules/Sources/JetpackStats/Constants.swift @@ -24,7 +24,7 @@ enum Constants { )) static let background = Color(UIColor( - light: CSColor.Gray.shade(.shade0), + light: UIColor.secondarySystemBackground, dark: UIColor.systemBackground )) diff --git a/Modules/Sources/JetpackStats/Services/Data/SiteMetric.swift b/Modules/Sources/JetpackStats/Services/Data/SiteMetric.swift index 39d34d09430c..89f9c5cc400f 100644 --- a/Modules/Sources/JetpackStats/Services/Data/SiteMetric.swift +++ b/Modules/Sources/JetpackStats/Services/Data/SiteMetric.swift @@ -28,7 +28,7 @@ enum SiteMetric: CaseIterable, Identifiable, Sendable { var systemImage: String { switch self { case .views: "eyeglasses" - case .visitors: "person.2" + case .visitors: "person" case .likes: "star" case .comments: "bubble.left" case .posts: "paragraphsign" diff --git a/Modules/Sources/JetpackStats/Services/Data/TopListItemType.swift b/Modules/Sources/JetpackStats/Services/Data/TopListItemType.swift index f40ac844160e..ba4f964daec1 100644 --- a/Modules/Sources/JetpackStats/Services/Data/TopListItemType.swift +++ b/Modules/Sources/JetpackStats/Services/Data/TopListItemType.swift @@ -32,7 +32,7 @@ enum TopListItemType: Identifiable, CaseIterable, Sendable { case .postsAndPages: "text.page" case .referrers: "link" case .locations: "map" - case .authors: "person.2" + case .authors: "person" case .externalLinks: "cursorarrow.click" case .fileDownloads: "arrow.down.circle" case .searchTerms: "magnifyingglass" diff --git a/Modules/Sources/JetpackStats/Utilities/Modifiers/CardModifier.swift b/Modules/Sources/JetpackStats/Utilities/Modifiers/CardModifier.swift index 4c7ee0f94ba0..ba5b344e57bb 100644 --- a/Modules/Sources/JetpackStats/Utilities/Modifiers/CardModifier.swift +++ b/Modules/Sources/JetpackStats/Utilities/Modifiers/CardModifier.swift @@ -5,11 +5,11 @@ struct CardModifier: ViewModifier { content .background(Color(UIColor(light: .systemBackground, dark: .secondarySystemBackground))) .overlay( - RoundedRectangle(cornerRadius: 16) + RoundedRectangle(cornerRadius: 26) .stroke(Color(.opaqueSeparator), lineWidth: 0.5) ) - .clipShape(RoundedRectangle(cornerRadius: 16)) - .padding(.horizontal, Constants.step2 / 2) + .clipShape(RoundedRectangle(cornerRadius: 26)) + .padding(.horizontal, Constants.step1) } } diff --git a/Modules/Sources/JetpackStats/Views/BadgeTrendIndicator.swift b/Modules/Sources/JetpackStats/Views/BadgeTrendIndicator.swift index 86d50906c6f5..f58f03856105 100644 --- a/Modules/Sources/JetpackStats/Views/BadgeTrendIndicator.swift +++ b/Modules/Sources/JetpackStats/Views/BadgeTrendIndicator.swift @@ -19,7 +19,7 @@ struct BadgeTrendIndicator: View { .padding(.horizontal, 8) .padding(.vertical, 6) .background(trend.sentiment.backgroundColor) - .cornerRadius(6) + .cornerRadius(Constants.step1 / 2) .animation(.spring, value: trend.percentage) } } diff --git a/Modules/Sources/JetpackStats/Views/HeatmapView.swift b/Modules/Sources/JetpackStats/Views/HeatmapView.swift index a78b9e9fbe3f..7fab20725685 100644 --- a/Modules/Sources/JetpackStats/Views/HeatmapView.swift +++ b/Modules/Sources/JetpackStats/Views/HeatmapView.swift @@ -40,7 +40,7 @@ struct HeatmapCellView: View { } var body: some View { - RoundedRectangle(cornerRadius: 4) + RoundedRectangle(cornerRadius: Constants.step1) .fill(color) .overlay { if value > 0 { @@ -82,7 +82,7 @@ struct HeatmapLegendView: View { HStack(spacing: 3) { ForEach(0..<5) { level in - RoundedRectangle(cornerRadius: 4) + RoundedRectangle(cornerRadius: Constants.step1) .fill(heatmapColor(for: Double(level) / 4.0)) .frame(width: 16, height: 16) } diff --git a/Modules/Sources/JetpackStats/Views/MetricsOverviewTabView.swift b/Modules/Sources/JetpackStats/Views/MetricsOverviewTabView.swift index e1eccae16656..e3b9481d3dbe 100644 --- a/Modules/Sources/JetpackStats/Views/MetricsOverviewTabView.swift +++ b/Modules/Sources/JetpackStats/Views/MetricsOverviewTabView.swift @@ -15,7 +15,7 @@ struct MetricsOverviewTabView: View { let data: [MetricData] @Binding var selectedMetric: SiteMetric - @ScaledMetric(relativeTo: .title) private var minTabWidth: CGFloat = 102 + @ScaledMetric(relativeTo: .title) private var minTabWidth: CGFloat = 96 var body: some View { ScrollViewReader { proxy in @@ -70,9 +70,10 @@ private struct MetricItemView: View { VStack(alignment: .leading, spacing: 0) { tabContent .padding(.vertical, Constants.step2) - .padding(.leading, Constants.step2) + .padding(.leading, Constants.step3) selectionIndicator - .padding(.horizontal, Constants.step2) + .padding(.leading, Constants.step3) + .padding(.trailing, Constants.step1) } } .buttonStyle(.plain) diff --git a/Modules/Sources/JetpackStats/Views/StatsCardTitleView.swift b/Modules/Sources/JetpackStats/Views/StatsCardTitleView.swift index 965d92fbdc69..cec9ff7df394 100644 --- a/Modules/Sources/JetpackStats/Views/StatsCardTitleView.swift +++ b/Modules/Sources/JetpackStats/Views/StatsCardTitleView.swift @@ -21,6 +21,7 @@ struct StatsCardTitleView: View { private var content: some View { let title = Text(title) .font(.headline) + .foregroundColor(.secondary) if showChevron { // Note: had to do that to fix the animation issuse with Menu // hiding the image. diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListItemBarBackground.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListItemBarBackground.swift index 34261e77a1dd..438b40f8a63a 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListItemBarBackground.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemBarBackground.swift @@ -10,7 +10,7 @@ struct TopListItemBarBackground: View { var body: some View { GeometryReader { geometry in HStack(spacing: 0) { - RoundedRectangle(cornerRadius: 6) + RoundedRectangle(cornerRadius: Constants.step1) .fill(barColor.opacity(colorScheme == .light ? 0.09 : 0.25)) .frame(width: barWidth(in: geometry)) Spacer(minLength: 0) diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift index a77b4af4b474..5780503fcbe4 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift @@ -79,7 +79,7 @@ struct TopListItemView: View { maxValue: maxValue, barColor: metric.primaryColor ) - .padding(.horizontal, -(Constants.step2 / 2)) + .padding(.horizontal, -Constants.step1) ) } } From 92f585350493c144b1f56877a16dfdbf72053d82 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Sat, 26 Jul 2025 09:55:52 -0400 Subject: [PATCH 109/349] Switch to List --- .../JetpackStats/Screens/TrafficTabView.swift | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/Modules/Sources/JetpackStats/Screens/TrafficTabView.swift b/Modules/Sources/JetpackStats/Screens/TrafficTabView.swift index e975a0889dc5..1a679a3123b0 100644 --- a/Modules/Sources/JetpackStats/Screens/TrafficTabView.swift +++ b/Modules/Sources/JetpackStats/Screens/TrafficTabView.swift @@ -12,14 +12,16 @@ struct TrafficTabView: View { } var body: some View { - ScrollView { - VStack(spacing: Constants.step3) { - ForEach(viewModels, id: \.id) { viewModel in - makeItem(for: viewModel) - } + List { + ForEach(viewModels, id: \.id) { viewModel in + makeItem(for: viewModel) + .padding(.vertical, Constants.step1) } - .padding(.vertical, Constants.step2) + .listRowSeparator(.hidden) + .listRowBackground(Color.clear) + .listRowInsets(.zero) } + .listStyle(.plain) .onAppear { configureViewModels() } @@ -71,6 +73,16 @@ struct TrafficTabView: View { selection: .init(item: .postsAndPages, metric: .views), dateRange: dateRange, service: context.service + ), + TopListCardViewModel( + selection: .init(item: .referrers, metric: .views), + dateRange: dateRange, + service: context.service + ), + TopListCardViewModel( + selection: .init(item: .locations, metric: .views), + dateRange: dateRange, + service: context.service ) ] } From d8c058168251c8f058e36a3866b25df448a15f1b Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Sat, 26 Jul 2025 10:02:20 -0400 Subject: [PATCH 110/349] Segments on top --- Modules/Sources/JetpackStats/Screens/TrafficTabView.swift | 1 + .../JetpackStats/Views/MetricsOverviewTabView.swift | 8 +++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Modules/Sources/JetpackStats/Screens/TrafficTabView.swift b/Modules/Sources/JetpackStats/Screens/TrafficTabView.swift index 1a679a3123b0..a41c60ac461b 100644 --- a/Modules/Sources/JetpackStats/Screens/TrafficTabView.swift +++ b/Modules/Sources/JetpackStats/Screens/TrafficTabView.swift @@ -16,6 +16,7 @@ struct TrafficTabView: View { ForEach(viewModels, id: \.id) { viewModel in makeItem(for: viewModel) .padding(.vertical, Constants.step1) + .padding(.top, viewModel.id == viewModels.first?.id ? 8 : 0) } .listRowSeparator(.hidden) .listRowBackground(Color.clear) diff --git a/Modules/Sources/JetpackStats/Views/MetricsOverviewTabView.swift b/Modules/Sources/JetpackStats/Views/MetricsOverviewTabView.swift index e3b9481d3dbe..c6563efc78e7 100644 --- a/Modules/Sources/JetpackStats/Views/MetricsOverviewTabView.swift +++ b/Modules/Sources/JetpackStats/Views/MetricsOverviewTabView.swift @@ -68,12 +68,14 @@ private struct MetricItemView: View { var body: some View { Button(action: onTap) { VStack(alignment: .leading, spacing: 0) { - tabContent - .padding(.vertical, Constants.step2) - .padding(.leading, Constants.step3) selectionIndicator .padding(.leading, Constants.step3) .padding(.trailing, Constants.step1) + tabContent + .padding(.top, Constants.step1) + .padding(.bottom, Constants.step2) + .padding(.leading, Constants.step3) + } } .buttonStyle(.plain) From b9617406a5367befc94a0a12841bddd0c3cf0a42 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Sat, 26 Jul 2025 10:12:24 -0400 Subject: [PATCH 111/349] Update chart to take more horizontal space --- Modules/Sources/JetpackStats/Cards/ChartCard.swift | 4 +--- Modules/Sources/JetpackStats/Views/StatsCardTitleView.swift | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/Modules/Sources/JetpackStats/Cards/ChartCard.swift b/Modules/Sources/JetpackStats/Cards/ChartCard.swift index 964bcdd1acbd..a48b6fc529f0 100644 --- a/Modules/Sources/JetpackStats/Cards/ChartCard.swift +++ b/Modules/Sources/JetpackStats/Cards/ChartCard.swift @@ -163,10 +163,8 @@ struct ChartCard: View { ) chartContentView(data: data) .frame(height: chartHeight) + .padding(.horizontal, -Constants.step1) .transition(.push(from: .trailing).combined(with: .opacity).combined(with: .scale)) - .overlay(alignment: .topLeading) { - - } } } diff --git a/Modules/Sources/JetpackStats/Views/StatsCardTitleView.swift b/Modules/Sources/JetpackStats/Views/StatsCardTitleView.swift index cec9ff7df394..8a3c51bfdad8 100644 --- a/Modules/Sources/JetpackStats/Views/StatsCardTitleView.swift +++ b/Modules/Sources/JetpackStats/Views/StatsCardTitleView.swift @@ -21,7 +21,7 @@ struct StatsCardTitleView: View { private var content: some View { let title = Text(title) .font(.headline) - .foregroundColor(.secondary) + .foregroundColor(.primary) if showChevron { // Note: had to do that to fix the animation issuse with Menu // hiding the image. From 12a5265671f8f4ac7095721104a0eb99daa64ded Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Sat, 26 Jul 2025 10:13:39 -0400 Subject: [PATCH 112/349] Improve more menu alignment --- Modules/Sources/JetpackStats/Cards/ChartCard.swift | 2 +- Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift | 2 +- Modules/Sources/JetpackStats/Cards/TopListCard.swift | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Modules/Sources/JetpackStats/Cards/ChartCard.swift b/Modules/Sources/JetpackStats/Cards/ChartCard.swift index a48b6fc529f0..72f5072e4036 100644 --- a/Modules/Sources/JetpackStats/Cards/ChartCard.swift +++ b/Modules/Sources/JetpackStats/Cards/ChartCard.swift @@ -111,7 +111,7 @@ struct ChartCard: View { Image(systemName: "ellipsis") .font(.body) .foregroundColor(.secondary) - .frame(width: 50, height: 50) + .frame(width: 56, height: 50) } .tint(Color.primary) .sheet(isPresented: $isShowingRawData) { diff --git a/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift b/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift index 6744e78a80fb..8c77e74f6c92 100644 --- a/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift +++ b/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift @@ -167,7 +167,7 @@ struct StandaloneChartCard: View { Image(systemName: "ellipsis") .font(.body) .foregroundColor(.secondary) - .frame(width: 50, height: 50) + .frame(width: 56, height: 50) } .tint(Color.primary) } diff --git a/Modules/Sources/JetpackStats/Cards/TopListCard.swift b/Modules/Sources/JetpackStats/Cards/TopListCard.swift index 3bae685f9b0a..b7893ec80057 100644 --- a/Modules/Sources/JetpackStats/Cards/TopListCard.swift +++ b/Modules/Sources/JetpackStats/Cards/TopListCard.swift @@ -111,7 +111,7 @@ struct TopListCard: View { Image(systemName: "ellipsis") .font(.body) .foregroundColor(.secondary) - .frame(width: 50, height: 50) + .frame(width: 56, height: 50) } .tint(Color.primary) } From dd8575e5efef9895c1e6f1635e5913bfb99693f2 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Sat, 26 Jul 2025 10:32:49 -0400 Subject: [PATCH 113/349] More breezing room --- Modules/Sources/JetpackStats/Cards/ChartCard.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Modules/Sources/JetpackStats/Cards/ChartCard.swift b/Modules/Sources/JetpackStats/Cards/ChartCard.swift index 72f5072e4036..9870e035cdab 100644 --- a/Modules/Sources/JetpackStats/Cards/ChartCard.swift +++ b/Modules/Sources/JetpackStats/Cards/ChartCard.swift @@ -21,7 +21,7 @@ struct ChartCard: View { var body: some View { VStack(spacing: 0) { - VStack(spacing: Constants.step1) { + VStack(spacing: Constants.step1 / 2) { headerView(for: selectedMetric) .unredacted() contentView @@ -53,7 +53,7 @@ struct ChartCard: View { @ViewBuilder private var contentView: some View { - VStack(spacing: Constants.step1 / 2) { + VStack(spacing: Constants.step2) { // Showing currently selected (not loaded period) by design ChartLegendView( metric: selectedMetric, From 3a193fe40e1d35dbd76b515c46c208cae60837d5 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Sat, 26 Jul 2025 10:34:09 -0400 Subject: [PATCH 114/349] Show top 5 --- Modules/Sources/JetpackStats/Cards/TopListCard.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/JetpackStats/Cards/TopListCard.swift b/Modules/Sources/JetpackStats/Cards/TopListCard.swift index b7893ec80057..9960cc58a30d 100644 --- a/Modules/Sources/JetpackStats/Cards/TopListCard.swift +++ b/Modules/Sources/JetpackStats/Cards/TopListCard.swift @@ -5,7 +5,7 @@ struct TopListCard: View { @Environment(\.context) var context - private let itemLimit = 6 + private let itemLimit = 5 init(viewModel: TopListCardViewModel) { self.viewModel = viewModel From 6b4ca6548bba3fae78b70bb78115c70f8aa5a939 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Sat, 26 Jul 2025 11:45:14 -0400 Subject: [PATCH 115/349] Simplify TopListItemView requirements --- .../Views/TopList/TopListItemView.swift | 20 +++++++++---------- .../Views/TopList/TopListItemsView.swift | 4 ++-- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift index 5780503fcbe4..cd5ac63fe8b7 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift @@ -1,8 +1,8 @@ import SwiftUI struct TopListItemView: View { - let currentItem: any TopListItem - let previousItem: (any TopListItem)? + let item: any TopListItem + let previousValue: Int? let metric: SiteMetric let maxValue: Int let dateRange: StatsDateRange @@ -28,7 +28,7 @@ struct TopListItemView: View { var content: some View { HStack(spacing: 0) { // Content-specific view - switch currentItem { + switch item { case let post as TopListData.Post: TopListPostRowView(item: post) case let referrer as TopListData.Referrer: @@ -48,7 +48,7 @@ struct TopListItemView: View { case let archiveItem as TopListData.ArchiveItem: TopListArchiveItemRowView(item: archiveItem) default: - let _ = assertionFailure("unsupported item: \(currentItem)") + let _ = assertionFailure("unsupported item: \(item)") EmptyView() } @@ -56,7 +56,7 @@ struct TopListItemView: View { // Metrics view ZStack(alignment: .trailing) { - if previousItem != nil { + if previousValue != nil { // Reserve space to avoid junky animations when changing period Text("+4.8K (31.2%)") .font(.caption.weight(.medium)).tracking(-0.33) @@ -64,8 +64,8 @@ struct TopListItemView: View { } TopListMetricsView( - currentValue: currentItem.metrics[metric] ?? 0, - previousValue: previousItem?.metrics[metric], + currentValue: item.metrics[metric] ?? 0, + previousValue: previousValue, metric: metric, showChevron: hasDetails ) @@ -75,7 +75,7 @@ struct TopListItemView: View { .padding(.vertical, 7) .background( TopListItemBarBackground( - value: currentItem.metrics[metric] ?? 0, + value: item.metrics[metric] ?? 0, maxValue: maxValue, barColor: metric.primaryColor ) @@ -91,7 +91,7 @@ private extension TopListItemView { guard !isNavigationDisabled else { return false } - switch currentItem { + switch item { case is TopListData.Post: return true case is TopListData.ArchiveItem: @@ -106,7 +106,7 @@ private extension TopListItemView { } func navigateToDetails() { - switch currentItem { + switch item { case let post as TopListData.Post: let detailsView = PostStatsView(post: post, dateRange: dateRange) .environment(\.context, context) diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift index f9a3577d5cdc..2f4105143bdd 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift @@ -57,8 +57,8 @@ struct TopListItemsView: View { private func makeView(for item: any TopListItem) -> some View { TopListItemView( - currentItem: item, - previousItem: data.previousItem(for: item), + item: item, + previousValue: data.previousItem(for: item)?.metrics[data.metric], metric: data.metric, maxValue: data.maxValue, dateRange: dateRange, From c7cfdbe1a3f80efa7af1f513b640a93985bfcef7 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Sat, 26 Jul 2025 11:56:28 -0400 Subject: [PATCH 116/349] Add previews for top list items --- .../Views/TopList/TopListItemView.swift | 236 ++++++++++++++++++ 1 file changed, 236 insertions(+) diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift index cd5ac63fe8b7..16ddce294fe0 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift @@ -131,3 +131,239 @@ private extension TopListItemView { } } } + +// MARK: - Preview + +#Preview { + ScrollView { + VStack(spacing: 24) { + makePreviewItems() + } + .padding() + } +} + +@ViewBuilder +private func makePreviewItems() -> some View { + // Posts & Pages + VStack(spacing: 8) { + makePreviewItem( + TopListData.Post( + title: "Getting Started with SwiftUI: A Comprehensive Guide", + postID: "1234", + postURL: URL(string: "https://example.com/swiftui-guide"), + date: Date().addingTimeInterval(-86400), + type: "post", + author: "John Doe", + metrics: SiteMetricsSet(views: 50000) + ), + previousValue: 45000 + ) + + makePreviewItem( + TopListData.Post( + title: "About Us", + postID: "5678", + postURL: nil, + date: nil, + type: "page", + author: nil, + metrics: SiteMetricsSet(views: 3421) + ), + previousValue: 3500 + ) + } + + // Authors + VStack(spacing: 8) { + makePreviewItem( + TopListData.Author( + name: "Sarah Johnson", + userId: "100", + role: "Administrator", + metrics: SiteMetricsSet(views: 50000), + avatarURL: nil, + posts: nil + ), + previousValue: 48000 + ) + + makePreviewItem( + TopListData.Author( + name: "Michael Chen", + userId: "101", + role: "Editor", + metrics: SiteMetricsSet(views: 23100), + avatarURL: nil, + posts: nil + ), + previousValue: nil + ) + } + + // Referrers + VStack(spacing: 8) { + makePreviewItem( + TopListData.Referrer( + name: "Google Search", + domain: "google.com", + iconURL: URL(string: "https://www.google.com/favicon.ico"), + children: [], + metrics: SiteMetricsSet(views: 50000) + ), + previousValue: 42000 + ) + + makePreviewItem( + TopListData.Referrer( + name: "Direct Traffic", + domain: nil, + iconURL: nil, + children: [], + metrics: SiteMetricsSet(views: 12300) + ), + previousValue: 15000 + ) + } + + // Locations + VStack(spacing: 8) { + makePreviewItem( + TopListData.Location( + country: "United States", + flag: "🇺🇸", + countryCode: "US", + metrics: SiteMetricsSet(views: 50000) + ), + previousValue: 47500 + ) + + makePreviewItem( + TopListData.Location( + country: "United Kingdom", + flag: "🇬🇧", + countryCode: "GB", + metrics: SiteMetricsSet(views: 15600) + ), + previousValue: nil + ) + } + + // External Links + VStack(spacing: 8) { + makePreviewItem( + TopListData.ExternalLink( + url: "https://developer.apple.com/documentation/swiftui", + title: "SwiftUI Documentation", + metrics: SiteMetricsSet(views: 50000) + ), + previousValue: 52000 + ) + + makePreviewItem( + TopListData.ExternalLink( + url: "https://github.com/wordpress/wordpress-ios", + title: nil, + metrics: SiteMetricsSet(views: 1250) + ), + previousValue: 1100 + ) + } + + // File Downloads + VStack(spacing: 8) { + makePreviewItem( + TopListData.FileDownload( + fileName: "wordpress-guide-2024.pdf", + filePath: "/downloads/guides/wordpress-guide-2024.pdf", + metrics: SiteMetricsSet(downloads: 50000) + ), + previousValue: 46000, + metric: .downloads + ) + + makePreviewItem( + TopListData.FileDownload( + fileName: "sample-theme.zip", + filePath: nil, + metrics: SiteMetricsSet(downloads: 1230) + ), + previousValue: nil, + metric: .downloads + ) + } + + // Search Terms + VStack(spacing: 8) { + makePreviewItem( + TopListData.SearchTerm( + term: "wordpress tutorial", + metrics: SiteMetricsSet(views: 50000) + ), + previousValue: 48500 + ) + + makePreviewItem( + TopListData.SearchTerm( + term: "how to install plugins", + metrics: SiteMetricsSet(views: 890) + ), + previousValue: 950 + ) + } + + // Videos + VStack(spacing: 8) { + makePreviewItem( + TopListData.Video( + title: "WordPress 6.0 Features Overview", + postId: "9012", + videoUrl: URL(string: "https://example.com/videos/wp-6-features"), + metrics: SiteMetricsSet(views: 50000) + ), + previousValue: 44000 + ) + + makePreviewItem( + TopListData.Video( + title: "Building Your First Theme", + postId: "9013", + videoUrl: nil, + metrics: SiteMetricsSet(views: 3210) + ), + previousValue: nil + ) + } + + // Archive Items + VStack(spacing: 8) { + makePreviewItem( + TopListData.ArchiveItem( + href: "/2024/03/", + value: "March 2024", + metrics: SiteMetricsSet(views: 50000) + ), + previousValue: 51000 + ) + + makePreviewItem( + TopListData.ArchiveItem( + href: "/category/tutorials/", + value: "Tutorials", + metrics: SiteMetricsSet(views: 12300) + ), + previousValue: 11000 + ) + } +} + +private func makePreviewItem(_ item: any TopListItem, previousValue: Int? = nil, metric: SiteMetric = .views) -> some View { + TopListItemView( + item: item, + previousValue: previousValue, + metric: metric, + maxValue: 50000, + dateRange: Calendar.demo.makeDateRange(for: .last7Days) + ) + .padding(.horizontal) +} From 5269b0cd1fd6098db79a68adecea31e7a704f908 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Sat, 26 Jul 2025 12:05:32 -0400 Subject: [PATCH 117/349] Add cache for mock data --- .../Services/Data/TopListChartData.swift | 24 ++++++++++++++++- .../JetpackStats/Views/CountriesMapView.swift | 26 +++++++++---------- 2 files changed, 36 insertions(+), 14 deletions(-) diff --git a/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift b/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift index ec34b380b6f7..9dd3f9c085ad 100644 --- a/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift +++ b/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift @@ -32,11 +32,28 @@ final class TopListChartData { // MARK: - Mock Data extension TopListChartData { + private struct CacheKey: Hashable { + let itemType: TopListItemType + let metric: SiteMetric + let itemCount: Int + } + + @MainActor + private static var mockDataCache: [CacheKey: TopListChartData] = [:] + + @MainActor static func mock( for itemType: TopListItemType, metric: SiteMetric = .views, itemCount: Int = 6 ) -> TopListChartData { + let cacheKey = CacheKey(itemType: itemType, metric: metric, itemCount: itemCount) + + // Return cached data if available + if let cachedData = mockDataCache[cacheKey] { + return cachedData + } + let currentItems = mockItems(for: itemType, metric: metric, count: itemCount) // Create previous items dictionary @@ -50,13 +67,18 @@ extension TopListChartData { .compactMap { $0.metrics[metric] } .max() ?? 1 - return TopListChartData( + let chartData = TopListChartData( item: itemType, metric: metric, items: currentItems, previousItems: previousItemsDict, maxValue: maxValue ) + + // Cache the generated data + mockDataCache[cacheKey] = chartData + + return chartData } private static func mockItems( diff --git a/Modules/Sources/JetpackStats/Views/CountriesMapView.swift b/Modules/Sources/JetpackStats/Views/CountriesMapView.swift index 2c3060eca566..066578a2fba3 100644 --- a/Modules/Sources/JetpackStats/Views/CountriesMapView.swift +++ b/Modules/Sources/JetpackStats/Views/CountriesMapView.swift @@ -4,18 +4,18 @@ import FSInteractiveMap struct CountriesMapView: UIViewRepresentable { let data: CountriesMapData let primaryColor: UIColor - + func makeUIView(context: Context) -> FSInteractiveMapView { let mapView = FSInteractiveMapView(frame: .zero) mapView.backgroundColor = UIColor.secondarySystemGroupedBackground return mapView } - + func updateUIView(_ mapView: FSInteractiveMapView, context: Context) { // Set basic map colors mapView.strokeColor = .secondarySystemGroupedBackground mapView.fillColor = UIColor(light: .systemGray5, dark: .systemGray6) - + // Load map with data and color axis let colors = [ primaryColor.withAlphaComponent(0.1), @@ -29,13 +29,13 @@ struct CountriesMapData { let minViewsCount: Int let maxViewsCount: Int let mapData: [String: NSNumber] - + init(locations: [TopListData.Location]) { let sortedLocations = locations.sorted { ($0.metrics.views ?? 0) > ($1.metrics.views ?? 0) } - + self.minViewsCount = sortedLocations.last?.metrics.views ?? 0 self.maxViewsCount = sortedLocations.first?.metrics.views ?? 0 - + self.mapData = locations.reduce(into: [String: NSNumber]()) { result, location in if let countryCode = location.countryCode, let views = location.metrics.views { @@ -48,22 +48,22 @@ struct CountriesMapData { struct CountriesMapContainer: View { let data: CountriesMapData let primaryColor: Color - + var body: some View { VStack(spacing: 12) { // Map View CountriesMapView(data: data, primaryColor: UIColor(primaryColor)) .frame(height: 224) .cornerRadius(8) - + // Gradient Legend HStack(spacing: 0) { Text(data.minViewsCount.abbreviatedString()) .font(.footnote) .foregroundColor(.secondary) - + Spacer() - + LinearGradient( colors: [primaryColor.opacity(0.1), primaryColor], startPoint: .leading, @@ -71,9 +71,9 @@ struct CountriesMapContainer: View { ) .frame(height: 10) .cornerRadius(5) - + Spacer() - + Text(data.maxViewsCount.abbreviatedString()) .font(.footnote) .foregroundColor(.secondary) @@ -90,7 +90,7 @@ private extension Int { func abbreviatedString() -> String { let formatter = NumberFormatter() formatter.maximumFractionDigits = 1 - + if self >= 1_000_000 { return "\(formatter.string(from: NSNumber(value: Double(self) / 1_000_000)) ?? "0")M" } else if self >= 1_000 { From a09bb44b663e67f556035a55d44af8126916e1c9 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Sat, 26 Jul 2025 12:09:59 -0400 Subject: [PATCH 118/349] Simplify mock data generation --- .../Services/Data/TopListChartData.swift | 74 ++++++++----------- 1 file changed, 31 insertions(+), 43 deletions(-) diff --git a/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift b/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift index 9dd3f9c085ad..9c992af2670a 100644 --- a/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift +++ b/Modules/Sources/JetpackStats/Services/Data/TopListChartData.swift @@ -54,7 +54,8 @@ extension TopListChartData { return cachedData } - let currentItems = mockItems(for: itemType, metric: metric, count: itemCount) + let currentItems = mockItems(for: itemType, metric: metric) + .prefix(itemCount) // Create previous items dictionary var previousItemsDict: [TopListItemID: any TopListItem] = [:] @@ -70,7 +71,7 @@ extension TopListChartData { let chartData = TopListChartData( item: itemType, metric: metric, - items: currentItems, + items: Array(currentItems), previousItems: previousItemsDict, maxValue: maxValue ) @@ -81,34 +82,21 @@ extension TopListChartData { return chartData } - private static func mockItems( - for item: TopListItemType, - metric: SiteMetric, - count: Int - ) -> [any TopListItem] { + private static func mockItems(for item: TopListItemType, metric: SiteMetric) -> [any TopListItem] { switch item { - case .postsAndPages: - return mockPosts(metric: metric, count: count) - case .referrers: - return mockReferrers(metric: metric, count: count) - case .locations: - return mockLocations(metric: metric, count: count) - case .authors: - return mockAuthors(metric: metric, count: count) - case .externalLinks: - return mockExternalLinks(metric: metric, count: count) - case .fileDownloads: - return mockFileDownloads(metric: metric, count: count) - case .searchTerms: - return mockSearchTerms(metric: metric, count: count) - case .videos: - return mockVideos(metric: metric, count: count) - case .archive: - return mockArchive(metric: metric, count: count) + case .postsAndPages: mockPosts(metric: metric) + case .referrers: mockReferrers(metric: metric) + case .locations: mockLocations(metric: metric) + case .authors: mockAuthors(metric: metric) + case .externalLinks: mockExternalLinks(metric: metric) + case .fileDownloads: mockFileDownloads(metric: metric) + case .searchTerms: mockSearchTerms(metric: metric) + case .videos: mockVideos(metric: metric) + case .archive: mockArchive(metric: metric) } } - private static func mockPosts(metric: SiteMetric, count: Int) -> [TopListData.Post] { + private static func mockPosts(metric: SiteMetric) -> [TopListData.Post] { let posts = [ ("Getting Started with SwiftUI", "John Doe", 3500), ("Understanding Async/Await in Swift", "Jane Smith", 2800), @@ -120,7 +108,7 @@ extension TopListChartData { ("Debugging in Xcode", "Lisa Anderson", 850) ] - return posts.prefix(count).enumerated().map { index, data in + return posts.enumerated().map { index, data in let baseValue = data.2 let metrics = createMetrics(baseValue: baseValue, metric: metric) return TopListData.Post( @@ -135,7 +123,7 @@ extension TopListChartData { } } - private static func mockReferrers(metric: SiteMetric, count: Int) -> [TopListData.Referrer] { + private static func mockReferrers(metric: SiteMetric) -> [TopListData.Referrer] { let referrers = [ ("Google", "google.com", 4200), ("Twitter", "twitter.com", 3100), @@ -147,7 +135,7 @@ extension TopListChartData { ("Medium", "medium.com", 600) ] - return referrers.prefix(count).enumerated().map { index, data in + return referrers.enumerated().map { index, data in let baseValue = data.2 let metrics = createMetrics(baseValue: baseValue, metric: metric) return TopListData.Referrer( @@ -182,7 +170,7 @@ extension TopListChartData { } } - private static func mockLocations(metric: SiteMetric, count: Int) -> [TopListData.Location] { + private static func mockLocations(metric: SiteMetric) -> [TopListData.Location] { let locations = [ ("United States", "US", "🇺🇸", 5600), ("United Kingdom", "GB", "🇬🇧", 3200), @@ -194,7 +182,7 @@ extension TopListChartData { ("Netherlands", "NL", "🇳🇱", 900) ] - return locations.prefix(count).enumerated().map { index, data in + return locations.enumerated().map { index, data in let baseValue = data.3 let metrics = createMetrics(baseValue: baseValue, metric: metric) return TopListData.Location( @@ -206,7 +194,7 @@ extension TopListChartData { } } - private static func mockAuthors(metric: SiteMetric, count: Int) -> [TopListData.Author] { + private static func mockAuthors(metric: SiteMetric) -> [TopListData.Author] { let authors = [ ("Alex Thompson", "Editor", 1, 2400), ("Maria Garcia", "Contributor", 2, 2100), @@ -218,7 +206,7 @@ extension TopListChartData { ("Sarah Davis", "Contributor", 8, 400) ] - return authors.prefix(count).enumerated().map { index, data in + return authors.enumerated().map { index, data in let baseValue = data.3 let metrics = createMetrics(baseValue: baseValue, metric: metric) return TopListData.Author( @@ -231,7 +219,7 @@ extension TopListChartData { } } - private static func mockExternalLinks(metric: SiteMetric, count: Int) -> [TopListData.ExternalLink] { + private static func mockExternalLinks(metric: SiteMetric) -> [TopListData.ExternalLink] { let links = [ ("Apple Developer", "https://developer.apple.com", 1800), ("Swift.org", "https://swift.org", 1500), @@ -243,7 +231,7 @@ extension TopListChartData { ("SwiftUI Lab", "https://swiftui-lab.com", 300) ] - return links.prefix(count).enumerated().map { index, data in + return links.enumerated().map { index, data in let baseValue = data.2 let metrics = createMetrics(baseValue: baseValue, metric: metric) return TopListData.ExternalLink( @@ -254,7 +242,7 @@ extension TopListChartData { } } - private static func mockFileDownloads(metric: SiteMetric, count: Int) -> [TopListData.FileDownload] { + private static func mockFileDownloads(metric: SiteMetric) -> [TopListData.FileDownload] { let files = [ ("annual-report-2024.pdf", "/downloads/reports/annual-report-2024.pdf", 2500), ("swift-cheatsheet.pdf", "/downloads/docs/swift-cheatsheet.pdf", 2100), @@ -266,7 +254,7 @@ extension TopListChartData { ("dataset.csv", "/downloads/data/dataset.csv", 400) ] - return files.prefix(count).enumerated().map { index, data in + return files.enumerated().map { index, data in let baseValue = data.2 let metrics = createMetrics(baseValue: baseValue, metric: metric) return TopListData.FileDownload( @@ -277,7 +265,7 @@ extension TopListChartData { } } - private static func mockSearchTerms(metric: SiteMetric, count: Int) -> [TopListData.SearchTerm] { + private static func mockSearchTerms(metric: SiteMetric) -> [TopListData.SearchTerm] { let terms = [ ("swiftui tutorial", 3200), ("ios development guide", 2800), @@ -289,7 +277,7 @@ extension TopListChartData { ("swift best practices", 500) ] - return terms.prefix(count).enumerated().map { index, data in + return terms.enumerated().map { index, data in let baseValue = data.1 let metrics = createMetrics(baseValue: baseValue, metric: metric) return TopListData.SearchTerm( @@ -299,7 +287,7 @@ extension TopListChartData { } } - private static func mockVideos(metric: SiteMetric, count: Int) -> [TopListData.Video] { + private static func mockVideos(metric: SiteMetric) -> [TopListData.Video] { let videos = [ ("Getting Started with SwiftUI", "101", "https://example.com/videos/swiftui-intro.mp4", 4500), ("iOS Development Best Practices", "102", "https://example.com/videos/best-practices.mp4", 3800), @@ -311,7 +299,7 @@ extension TopListChartData { ("Testing Strategies", "108", "https://example.com/videos/testing.mp4", 700) ] - return videos.prefix(count).enumerated().map { index, data in + return videos.enumerated().map { index, data in let baseValue = data.3 let metrics = createMetrics(baseValue: baseValue, metric: metric) return TopListData.Video( @@ -323,7 +311,7 @@ extension TopListChartData { } } - private static func mockArchive(metric: SiteMetric, count: Int) -> [any TopListItem] { + private static func mockArchive(metric: SiteMetric) -> [any TopListItem] { // Create mock archive sections let archiveSections = [ ("pages", [ @@ -353,7 +341,7 @@ extension TopListChartData { ]) ] - return archiveSections.prefix(count).map { sectionData in + return archiveSections.map { sectionData in let sectionName = sectionData.0 let items = sectionData.1.map { itemData in let metrics = createMetrics(baseValue: itemData.1, metric: metric) From 7965ebcd282a1e4fa6fed8e979c1d39195a60ff3 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Sat, 26 Jul 2025 12:12:57 -0400 Subject: [PATCH 119/349] Set min height for bars --- .../JetpackStats/Views/TopList/TopListItemBarBackground.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListItemBarBackground.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListItemBarBackground.swift index 438b40f8a63a..11bea1aaa6fb 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/TopListItemBarBackground.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemBarBackground.swift @@ -12,7 +12,7 @@ struct TopListItemBarBackground: View { HStack(spacing: 0) { RoundedRectangle(cornerRadius: Constants.step1) .fill(barColor.opacity(colorScheme == .light ? 0.09 : 0.25)) - .frame(width: barWidth(in: geometry)) + .frame(width: max(8, barWidth(in: geometry))) Spacer(minLength: 0) } } From 761bea547ae13ae70a183c55b2105bf9c83ecc58 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Sat, 26 Jul 2025 12:37:39 -0400 Subject: [PATCH 120/349] Add initial implementation --- .../JetpackStats/Views/CountriesMapView.swift | 45 +- .../Views/InteractiveMapView.swift | 435 ++++++++++++++++++ 2 files changed, 457 insertions(+), 23 deletions(-) create mode 100644 Modules/Sources/JetpackStats/Views/InteractiveMapView.swift diff --git a/Modules/Sources/JetpackStats/Views/CountriesMapView.swift b/Modules/Sources/JetpackStats/Views/CountriesMapView.swift index 066578a2fba3..93c2af96158c 100644 --- a/Modules/Sources/JetpackStats/Views/CountriesMapView.swift +++ b/Modules/Sources/JetpackStats/Views/CountriesMapView.swift @@ -1,27 +1,20 @@ import SwiftUI -import FSInteractiveMap -struct CountriesMapView: UIViewRepresentable { +struct CountriesMapView: View { let data: CountriesMapData - let primaryColor: UIColor - - func makeUIView(context: Context) -> FSInteractiveMapView { - let mapView = FSInteractiveMapView(frame: .zero) - mapView.backgroundColor = UIColor.secondarySystemGroupedBackground - return mapView - } - - func updateUIView(_ mapView: FSInteractiveMapView, context: Context) { - // Set basic map colors - mapView.strokeColor = .secondarySystemGroupedBackground - mapView.fillColor = UIColor(light: .systemGray5, dark: .systemGray6) - - // Load map with data and color axis - let colors = [ - primaryColor.withAlphaComponent(0.1), - primaryColor - ] - mapView.loadMap("world-map", withData: data.mapData, colorAxis: colors) + let primaryColor: Color + + var body: some View { + InteractiveMapView( + svgResourceName: "world-map", + data: data.mapDataAsDouble, + colorAxis: [ + primaryColor.opacity(0.1), + primaryColor + ], + strokeColor: Color(UIColor.secondarySystemGroupedBackground), + fillColor: Color(UIColor(light: .systemGray5, dark: .systemGray6)) + ) } } @@ -29,6 +22,10 @@ struct CountriesMapData { let minViewsCount: Int let maxViewsCount: Int let mapData: [String: NSNumber] + + var mapDataAsDouble: [String: Double] { + mapData.mapValues { $0.doubleValue } + } init(locations: [TopListData.Location]) { let sortedLocations = locations.sorted { ($0.metrics.views ?? 0) > ($1.metrics.views ?? 0) } @@ -49,11 +46,13 @@ struct CountriesMapContainer: View { let data: CountriesMapData let primaryColor: Color + @ScaledMetric private var mapHeight = 200 + var body: some View { VStack(spacing: 12) { // Map View - CountriesMapView(data: data, primaryColor: UIColor(primaryColor)) - .frame(height: 224) + CountriesMapView(data: data, primaryColor: primaryColor) + .frame(height: mapHeight) .cornerRadius(8) // Gradient Legend diff --git a/Modules/Sources/JetpackStats/Views/InteractiveMapView.swift b/Modules/Sources/JetpackStats/Views/InteractiveMapView.swift new file mode 100644 index 000000000000..f11bf839fab6 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/InteractiveMapView.swift @@ -0,0 +1,435 @@ +import SwiftUI +import WebKit + +/// A native SwiftUI implementation of an interactive map view that displays SVG maps +/// with data-driven coloring of regions. +struct InteractiveMapView: View { + let svgResourceName: String + let data: [String: Double] + let colorAxis: [Color] + let strokeColor: Color + let fillColor: Color + + @State private var svgContent: String? + @State private var processedSVG: String? + + var body: some View { + ZStack { + if let processedSVG = processedSVG { + // Use WKWebView to render the SVG as it provides the best SVG support + SVGWebView(htmlContent: wrapSVGInHTML(processedSVG)) + .background(Color.clear) + } else { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + .onAppear { + loadAndProcessSVG() + } + .onChange(of: data) { _ in + if svgContent != nil { + processSVG() + } + } + } + + private func loadAndProcessSVG() { + print("InteractiveMapView: Loading SVG resource: \(svgResourceName)") + + // Try multiple approaches to load the SVG + if let svgPath = Bundle.module.path(forResource: svgResourceName, ofType: "svg"), + let content = try? String(contentsOfFile: svgPath) { + print("InteractiveMapView: Successfully loaded SVG from path, content length: \(content.count)") + svgContent = content + processSVG() + return + } + + // Try URL approach + if let url = Bundle.module.url(forResource: svgResourceName, withExtension: "svg"), + let content = try? String(contentsOf: url) { + print("InteractiveMapView: Successfully loaded SVG from URL, content length: \(content.count)") + svgContent = content + processSVG() + return + } + } + + private func processSVG() { + guard let svgContent = svgContent else { return } + + print("InteractiveMapView: Processing SVG with data for \(data.count) countries") + + // Find min and max values in the data + let values = data.values + let minValue = values.min() ?? 0 + let maxValue = values.max() ?? 1 + + print("InteractiveMapView: Data range: \(minValue) - \(maxValue)") + + var processedContent = svgContent + + // Process each country in the data + for (countryCode, value) in data { + let normalizedValue = (value - minValue) / (maxValue - minValue) + let color = interpolateColor(normalizedValue) + + // Replace fill color for paths with matching country codes + // SVG paths typically have id or class attributes with country codes + processedContent = processCountryInSVG(processedContent, countryCode: countryCode, color: color) + } + + // Update default fill color for countries without data + processedContent = updateDefaultColors(processedContent) + + print("InteractiveMapView: Finished processing SVG, final length: \(processedContent.count)") + + self.processedSVG = processedContent + } + + private func processCountryInSVG(_ svg: String, countryCode: String, color: Color) -> String { + var result = svg + let hexColor = color.toHex() + + // Look for path elements with country code as ID + // The SVG uses id="XX" where XX is the 2-letter country code + let pattern = "(]*?)(?:fill=\"[^\"]*\")?([^>]*?>)" + + if let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) { + let range = NSRange(location: 0, length: result.utf16.count) + result = regex.stringByReplacingMatches( + in: result, + options: [], + range: range, + withTemplate: "$1 fill=\"\(hexColor)\" style=\"fill:\(hexColor)\"$2" + ) + } + + return result + } + + private func updateDefaultColors(_ svg: String) -> String { + var result = svg + + // First, update the CSS class that defines default colors + let fillHex = fillColor.toHex() + let strokeHex = strokeColor.toHex() + + // Replace the .st0 class definition in the style tag + result = result.replacingOccurrences( + of: "\\.st0\\{[^}]*\\}", + with: ".st0{fill:\(fillHex);stroke:\(strokeHex);stroke-width:0.5;}", + options: .regularExpression + ) + + return result + } + + private func interpolateColor(_ value: Double) -> Color { + // Ensure we have at least 2 colors + guard colorAxis.count >= 2 else { + return colorAxis.first ?? .blue + } + + // Clamp value between 0 and 1 + let clampedValue = min(max(value, 0), 1) + + if colorAxis.count == 2 { + // Simple interpolation between two colors + return Color.interpolate(from: colorAxis[0], to: colorAxis[1], fraction: clampedValue) + } else { + // Multi-stop gradient interpolation + let scaledValue = clampedValue * Double(colorAxis.count - 1) + let lowerIndex = Int(scaledValue) + let upperIndex = min(lowerIndex + 1, colorAxis.count - 1) + let fraction = scaledValue - Double(lowerIndex) + + return Color.interpolate( + from: colorAxis[lowerIndex], + to: colorAxis[upperIndex], + fraction: fraction + ) + } + } + + private func wrapSVGInHTML(_ svg: String) -> String { + """ + + + + + + + + \(svg) + + + """ + } +} + +// MARK: - SVG WebView + +private struct SVGWebView: UIViewRepresentable { + let htmlContent: String + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeUIView(context: Context) -> WKWebView { + let configuration = WKWebViewConfiguration() + configuration.preferences.setValue(true, forKey: "allowFileAccessFromFileURLs") + + let webView = WKWebView(frame: .zero, configuration: configuration) + webView.scrollView.isScrollEnabled = false + webView.isOpaque = false + webView.backgroundColor = .clear + webView.scrollView.backgroundColor = .clear + webView.navigationDelegate = context.coordinator + + print("InteractiveMapView: Created WKWebView") + + return webView + } + + func updateUIView(_ webView: WKWebView, context: Context) { + print("InteractiveMapView: Loading HTML content, length: \(htmlContent.count)") + webView.loadHTMLString(htmlContent, baseURL: nil) + } + + class Coordinator: NSObject, WKNavigationDelegate { + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + print("InteractiveMapView: WebView finished loading") + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + print("InteractiveMapView: WebView failed to load: \(error)") + } + + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + print("InteractiveMapView: WebView failed provisional navigation: \(error)") + } + } +} + +// MARK: - Color Extensions + +private extension Color { + func toHex() -> String { + let uiColor = UIColor(self) + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + + uiColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + + return String(format: "#%02X%02X%02X", + Int(red * 255), + Int(green * 255), + Int(blue * 255)) + } + + static func interpolate(from: Color, to: Color, fraction: Double) -> Color { + let fromUIColor = UIColor(from) + let toUIColor = UIColor(to) + + var r1: CGFloat = 0, g1: CGFloat = 0, b1: CGFloat = 0, a1: CGFloat = 0 + var r2: CGFloat = 0, g2: CGFloat = 0, b2: CGFloat = 0, a2: CGFloat = 0 + + fromUIColor.getRed(&r1, green: &g1, blue: &b1, alpha: &a1) + toUIColor.getRed(&r2, green: &g2, blue: &b2, alpha: &a2) + + let r = r1 + (r2 - r1) * CGFloat(fraction) + let g = g1 + (g2 - g1) * CGFloat(fraction) + let b = b1 + (b2 - b1) * CGFloat(fraction) + let a = a1 + (a2 - a1) * CGFloat(fraction) + + return Color(UIColor(red: r, green: g, blue: b, alpha: a)) + } +} + + +// MARK: - Preview + +#Preview("Interactive Map") { + VStack(spacing: 20) { + // Full featured map + VStack(alignment: .leading, spacing: 8) { + Text("Countries by Views") + .font(.headline) + + InteractiveMapView( + svgResourceName: "world-map", + data: [ + "US": 15000, + "GB": 8500, + "CA": 6200, + "DE": 5100, + "FR": 4800, + "JP": 4200, + "AU": 3500, + "NL": 2800, + "IT": 2400, + "ES": 2100, + "BR": 1900, + "IN": 1700, + "MX": 1500, + "SE": 1200, + "NO": 1000, + "PL": 900, + "CH": 850, + "BE": 800, + "AT": 750, + "DK": 700, + "FI": 650, + "NZ": 600, + "IE": 550, + "PT": 500, + "CZ": 450 + ], + colorAxis: [ + Constants.Colors.blue.opacity(0.1), + Constants.Colors.blue + ], + strokeColor: Color(UIColor.secondarySystemGroupedBackground), + fillColor: Color(UIColor(light: .systemGray5, dark: .systemGray6)) + ) + .frame(height: 200) + .cornerRadius(8) + } + + // Different color scheme + VStack(alignment: .leading, spacing: 8) { + Text("Countries by Downloads") + .font(.headline) + + InteractiveMapView( + svgResourceName: "world-map", + data: [ + "US": 5000, + "GB": 2200, + "DE": 1800, + "CA": 1500, + "AU": 1200, + "FR": 1000, + "JP": 900, + "NL": 600, + "CH": 400, + "SE": 350 + ], + colorAxis: [ + Constants.Colors.green.opacity(0.2), + Constants.Colors.green.opacity(0.6), + Constants.Colors.green + ], + strokeColor: Color(UIColor.systemGray4), + fillColor: Color(UIColor.systemGray6) + ) + .frame(height: 200) + .cornerRadius(8) + } + + // Minimal data + VStack(alignment: .leading, spacing: 8) { + Text("Top 3 Countries") + .font(.headline) + + InteractiveMapView( + svgResourceName: "world-map", + data: [ + "US": 100, + "GB": 50, + "CA": 25 + ], + colorAxis: [ + Constants.Colors.purple.opacity(0.3), + Constants.Colors.purple + ], + strokeColor: Color(UIColor.separator), + fillColor: Color(UIColor.tertiarySystemFill) + ) + .frame(height: 200) + .cornerRadius(8) + } + } + .padding() + .background(Color(UIColor.systemGroupedBackground)) +} + +#Preview("Map with Countries Container") { + CountriesMapContainer( + data: CountriesMapData(locations: [ + TopListData.Location( + country: "United States", + flag: "🇺🇸", + countryCode: "US", + metrics: SiteMetricsSet(views: 10000) + ), + TopListData.Location( + country: "United Kingdom", + flag: "🇬🇧", + countryCode: "GB", + metrics: SiteMetricsSet(views: 4000) + ), + TopListData.Location( + country: "Canada", + flag: "🇨🇦", + countryCode: "CA", + metrics: SiteMetricsSet(views: 2800) + ), + TopListData.Location( + country: "Germany", + flag: "🇩🇪", + countryCode: "DE", + metrics: SiteMetricsSet(views: 2000) + ), + TopListData.Location( + country: "Australia", + flag: "🇦🇺", + countryCode: "AU", + metrics: SiteMetricsSet(views: 1600) + ), + TopListData.Location( + country: "France", + flag: "🇫🇷", + countryCode: "FR", + metrics: SiteMetricsSet(views: 1400) + ), + TopListData.Location( + country: "Japan", + flag: "🇯🇵", + countryCode: "JP", + metrics: SiteMetricsSet(views: 1100) + ), + TopListData.Location( + country: "Netherlands", + flag: "🇳🇱", + countryCode: "NL", + metrics: SiteMetricsSet(views: 800) + ) + ]), + primaryColor: Constants.Colors.blue + ) + .padding() + .background(Color(UIColor.systemGroupedBackground)) +} From 75c954045340b86fa139d4b452eaeaaee5acb398 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Sat, 26 Jul 2025 12:50:42 -0400 Subject: [PATCH 121/349] Rework await Task.detached(priority: .userInitiated) { --- .../Views/InteractiveMapView.swift | 462 +++++++----------- 1 file changed, 175 insertions(+), 287 deletions(-) diff --git a/Modules/Sources/JetpackStats/Views/InteractiveMapView.swift b/Modules/Sources/JetpackStats/Views/InteractiveMapView.swift index f11bf839fab6..8d816fb4fe45 100644 --- a/Modules/Sources/JetpackStats/Views/InteractiveMapView.swift +++ b/Modules/Sources/JetpackStats/Views/InteractiveMapView.swift @@ -25,132 +25,35 @@ struct InteractiveMapView: View { } } .onAppear { - loadAndProcessSVG() + Task { + if svgContent == nil { + svgContent = await loadSVG(resourceName: svgResourceName) + } + await updateMap() + } } .onChange(of: data) { _ in - if svgContent != nil { - processSVG() + Task { + await updateMap() } } } - private func loadAndProcessSVG() { - print("InteractiveMapView: Loading SVG resource: \(svgResourceName)") - - // Try multiple approaches to load the SVG - if let svgPath = Bundle.module.path(forResource: svgResourceName, ofType: "svg"), - let content = try? String(contentsOfFile: svgPath) { - print("InteractiveMapView: Successfully loaded SVG from path, content length: \(content.count)") - svgContent = content - processSVG() - return - } - - // Try URL approach - if let url = Bundle.module.url(forResource: svgResourceName, withExtension: "svg"), - let content = try? String(contentsOf: url) { - print("InteractiveMapView: Successfully loaded SVG from URL, content length: \(content.count)") - svgContent = content - processSVG() - return - } - } - - private func processSVG() { + @MainActor + private func updateMap() async { guard let svgContent = svgContent else { return } - print("InteractiveMapView: Processing SVG with data for \(data.count) countries") - - // Find min and max values in the data - let values = data.values - let minValue = values.min() ?? 0 - let maxValue = values.max() ?? 1 - - print("InteractiveMapView: Data range: \(minValue) - \(maxValue)") - - var processedContent = svgContent - - // Process each country in the data - for (countryCode, value) in data { - let normalizedValue = (value - minValue) / (maxValue - minValue) - let color = interpolateColor(normalizedValue) - - // Replace fill color for paths with matching country codes - // SVG paths typically have id or class attributes with country codes - processedContent = processCountryInSVG(processedContent, countryCode: countryCode, color: color) - } - - // Update default fill color for countries without data - processedContent = updateDefaultColors(processedContent) - - print("InteractiveMapView: Finished processing SVG, final length: \(processedContent.count)") - - self.processedSVG = processedContent - } - - private func processCountryInSVG(_ svg: String, countryCode: String, color: Color) -> String { - var result = svg - let hexColor = color.toHex() - - // Look for path elements with country code as ID - // The SVG uses id="XX" where XX is the 2-letter country code - let pattern = "(]*?)(?:fill=\"[^\"]*\")?([^>]*?>)" - - if let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) { - let range = NSRange(location: 0, length: result.utf16.count) - result = regex.stringByReplacingMatches( - in: result, - options: [], - range: range, - withTemplate: "$1 fill=\"\(hexColor)\" style=\"fill:\(hexColor)\"$2" - ) - } - - return result - } - - private func updateDefaultColors(_ svg: String) -> String { - var result = svg - - // First, update the CSS class that defines default colors - let fillHex = fillColor.toHex() - let strokeHex = strokeColor.toHex() - - // Replace the .st0 class definition in the style tag - result = result.replacingOccurrences( - of: "\\.st0\\{[^}]*\\}", - with: ".st0{fill:\(fillHex);stroke:\(strokeHex);stroke-width:0.5;}", - options: .regularExpression + // Process SVG with current data + let processed = await processSVG( + svgContent: svgContent, + data: data, + colorAxis: colorAxis, + strokeColor: strokeColor, + fillColor: fillColor ) - return result - } - - private func interpolateColor(_ value: Double) -> Color { - // Ensure we have at least 2 colors - guard colorAxis.count >= 2 else { - return colorAxis.first ?? .blue - } - - // Clamp value between 0 and 1 - let clampedValue = min(max(value, 0), 1) - - if colorAxis.count == 2 { - // Simple interpolation between two colors - return Color.interpolate(from: colorAxis[0], to: colorAxis[1], fraction: clampedValue) - } else { - // Multi-stop gradient interpolation - let scaledValue = clampedValue * Double(colorAxis.count - 1) - let lowerIndex = Int(scaledValue) - let upperIndex = min(lowerIndex + 1, colorAxis.count - 1) - let fraction = scaledValue - Double(lowerIndex) - - return Color.interpolate( - from: colorAxis[lowerIndex], - to: colorAxis[upperIndex], - fraction: fraction - ) - } + // Update UI + self.processedSVG = processed } private func wrapSVGInHTML(_ svg: String) -> String { @@ -186,6 +89,118 @@ struct InteractiveMapView: View { } } +// MARK: - SVG Processing + +private func loadSVG(resourceName: String) async -> String? { + // Try multiple approaches to load the SVG + if let svgPath = Bundle.module.path(forResource: resourceName, ofType: "svg"), + let content = try? String(contentsOfFile: svgPath) { + return content + } + + // Try URL approach + if let url = Bundle.module.url(forResource: resourceName, withExtension: "svg"), + let content = try? String(contentsOf: url) { + return content + } + + return nil +} + +private func processSVG( + svgContent: String, + data: [String: Double], + colorAxis: [Color], + strokeColor: Color, + fillColor: Color +) async -> String { + // Find min and max values in the data + let values = data.values + let minValue = values.min() ?? 0 + let maxValue = values.max() ?? 1 + + var processedContent = svgContent + + // Process each country in the data + for (countryCode, value) in data { + let normalizedValue = (value - minValue) / (maxValue - minValue) + let color = interpolateColor(normalizedValue, colorAxis: colorAxis) + + // Replace fill color for paths with matching country codes + processedContent = processCountryInSVG(processedContent, countryCode: countryCode, color: color) + } + + // Update default fill color for countries without data + processedContent = updateDefaultColors(processedContent, strokeColor: strokeColor, fillColor: fillColor) + + return processedContent +} + +private func processCountryInSVG(_ svg: String, countryCode: String, color: Color) -> String { + var result = svg + let hexColor = color.toHex() + + // Look for path elements with country code as ID + // The SVG uses id="XX" where XX is the 2-letter country code + let pattern = "(]*?)(?:fill=\"[^\"]*\")?([^>]*?>)" + + if let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) { + let range = NSRange(location: 0, length: result.utf16.count) + result = regex.stringByReplacingMatches( + in: result, + options: [], + range: range, + withTemplate: "$1 fill=\"\(hexColor)\" style=\"fill:\(hexColor)\"$2" + ) + } + + return result +} + +private func updateDefaultColors(_ svg: String, strokeColor: Color, fillColor: Color) -> String { + var result = svg + + // First, update the CSS class that defines default colors + let fillHex = fillColor.toHex() + let strokeHex = strokeColor.toHex() + + // Replace the .st0 class definition in the style tag + result = result.replacingOccurrences( + of: "\\.st0\\{[^}]*\\}", + with: ".st0{fill:\(fillHex);stroke:\(strokeHex);stroke-width:0.5;}", + options: .regularExpression + ) + + return result +} + +private func interpolateColor(_ value: Double, colorAxis: [Color]) -> Color { + // Ensure we have at least 2 colors + guard colorAxis.count >= 2 else { + return colorAxis.first ?? .blue + } + + // Clamp value between 0 and 1 + let clampedValue = min(max(value, 0), 1) + + if colorAxis.count == 2 { + // Simple interpolation between two colors + return Color.interpolate(from: colorAxis[0], to: colorAxis[1], fraction: clampedValue) + } else { + // Multi-stop gradient interpolation + let scaledValue = clampedValue * Double(colorAxis.count - 1) + let lowerIndex = Int(scaledValue) + let upperIndex = min(lowerIndex + 1, colorAxis.count - 1) + let fraction = scaledValue - Double(lowerIndex) + + return Color.interpolate( + from: colorAxis[lowerIndex], + to: colorAxis[upperIndex], + fraction: fraction + ) + } +} + // MARK: - SVG WebView private struct SVGWebView: UIViewRepresentable { @@ -205,28 +220,22 @@ private struct SVGWebView: UIViewRepresentable { webView.backgroundColor = .clear webView.scrollView.backgroundColor = .clear webView.navigationDelegate = context.coordinator - - print("InteractiveMapView: Created WKWebView") + + webView.alpha = 0 return webView } func updateUIView(_ webView: WKWebView, context: Context) { - print("InteractiveMapView: Loading HTML content, length: \(htmlContent.count)") webView.loadHTMLString(htmlContent, baseURL: nil) } class Coordinator: NSObject, WKNavigationDelegate { func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - print("InteractiveMapView: WebView finished loading") - } - - func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { - print("InteractiveMapView: WebView failed to load: \(error)") - } - - func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { - print("InteractiveMapView: WebView failed provisional navigation: \(error)") + // Fade in when content is loaded + UIView.animate(withDuration: 0.3, delay: 0.05, options: .curveEaseIn) { + webView.alpha = 1 + } } } } @@ -271,165 +280,44 @@ private extension Color { // MARK: - Preview -#Preview("Interactive Map") { - VStack(spacing: 20) { - // Full featured map - VStack(alignment: .leading, spacing: 8) { - Text("Countries by Views") - .font(.headline) - - InteractiveMapView( - svgResourceName: "world-map", - data: [ - "US": 15000, - "GB": 8500, - "CA": 6200, - "DE": 5100, - "FR": 4800, - "JP": 4200, - "AU": 3500, - "NL": 2800, - "IT": 2400, - "ES": 2100, - "BR": 1900, - "IN": 1700, - "MX": 1500, - "SE": 1200, - "NO": 1000, - "PL": 900, - "CH": 850, - "BE": 800, - "AT": 750, - "DK": 700, - "FI": 650, - "NZ": 600, - "IE": 550, - "PT": 500, - "CZ": 450 - ], - colorAxis: [ - Constants.Colors.blue.opacity(0.1), - Constants.Colors.blue - ], - strokeColor: Color(UIColor.secondarySystemGroupedBackground), - fillColor: Color(UIColor(light: .systemGray5, dark: .systemGray6)) - ) - .frame(height: 200) - .cornerRadius(8) - } - - // Different color scheme - VStack(alignment: .leading, spacing: 8) { - Text("Countries by Downloads") - .font(.headline) - - InteractiveMapView( - svgResourceName: "world-map", - data: [ - "US": 5000, - "GB": 2200, - "DE": 1800, - "CA": 1500, - "AU": 1200, - "FR": 1000, - "JP": 900, - "NL": 600, - "CH": 400, - "SE": 350 - ], - colorAxis: [ - Constants.Colors.green.opacity(0.2), - Constants.Colors.green.opacity(0.6), - Constants.Colors.green - ], - strokeColor: Color(UIColor.systemGray4), - fillColor: Color(UIColor.systemGray6) - ) - .frame(height: 200) - .cornerRadius(8) - } - - // Minimal data - VStack(alignment: .leading, spacing: 8) { - Text("Top 3 Countries") - .font(.headline) - - InteractiveMapView( - svgResourceName: "world-map", - data: [ - "US": 100, - "GB": 50, - "CA": 25 - ], - colorAxis: [ - Constants.Colors.purple.opacity(0.3), - Constants.Colors.purple - ], - strokeColor: Color(UIColor.separator), - fillColor: Color(UIColor.tertiarySystemFill) - ) - .frame(height: 200) - .cornerRadius(8) - } - } - .padding() - .background(Color(UIColor.systemGroupedBackground)) -} - -#Preview("Map with Countries Container") { - CountriesMapContainer( - data: CountriesMapData(locations: [ - TopListData.Location( - country: "United States", - flag: "🇺🇸", - countryCode: "US", - metrics: SiteMetricsSet(views: 10000) - ), - TopListData.Location( - country: "United Kingdom", - flag: "🇬🇧", - countryCode: "GB", - metrics: SiteMetricsSet(views: 4000) - ), - TopListData.Location( - country: "Canada", - flag: "🇨🇦", - countryCode: "CA", - metrics: SiteMetricsSet(views: 2800) - ), - TopListData.Location( - country: "Germany", - flag: "🇩🇪", - countryCode: "DE", - metrics: SiteMetricsSet(views: 2000) - ), - TopListData.Location( - country: "Australia", - flag: "🇦🇺", - countryCode: "AU", - metrics: SiteMetricsSet(views: 1600) - ), - TopListData.Location( - country: "France", - flag: "🇫🇷", - countryCode: "FR", - metrics: SiteMetricsSet(views: 1400) - ), - TopListData.Location( - country: "Japan", - flag: "🇯🇵", - countryCode: "JP", - metrics: SiteMetricsSet(views: 1100) - ), - TopListData.Location( - country: "Netherlands", - flag: "🇳🇱", - countryCode: "NL", - metrics: SiteMetricsSet(views: 800) - ) - ]), - primaryColor: Constants.Colors.blue +#Preview { + InteractiveMapView( + svgResourceName: "world-map", + data: [ + "US": 15000, + "GB": 8500, + "CA": 6200, + "DE": 5100, + "FR": 4800, + "JP": 4200, + "AU": 3500, + "NL": 2800, + "IT": 2400, + "ES": 2100, + "BR": 1900, + "IN": 1700, + "MX": 1500, + "SE": 1200, + "NO": 1000, + "PL": 900, + "CH": 850, + "BE": 800, + "AT": 750, + "DK": 700, + "FI": 650, + "NZ": 600, + "IE": 550, + "PT": 500, + "CZ": 450 + ], + colorAxis: [ + Constants.Colors.blue.opacity(0.1), + Constants.Colors.blue + ], + strokeColor: Color(UIColor.secondarySystemGroupedBackground), + fillColor: Color(UIColor(light: .systemGray5, dark: .systemGray6)) ) + .frame(height: 200) .padding() - .background(Color(UIColor.systemGroupedBackground)) + .background(Color(UIColor.systemBackground)) } From dc9e83fe30f3738745dd9d51f2c8511725d99de0 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Sat, 26 Jul 2025 13:25:14 -0400 Subject: [PATCH 122/349] Add zoom --- .../Extensions/Color+Extensions.swift | 43 +++++++++++++++ .../JetpackStats/Views/CountriesMapView.swift | 2 +- .../Views/InteractiveMapView.swift | 52 +++---------------- 3 files changed, 50 insertions(+), 47 deletions(-) create mode 100644 Modules/Sources/JetpackStats/Extensions/Color+Extensions.swift diff --git a/Modules/Sources/JetpackStats/Extensions/Color+Extensions.swift b/Modules/Sources/JetpackStats/Extensions/Color+Extensions.swift new file mode 100644 index 000000000000..b49bf74f4060 --- /dev/null +++ b/Modules/Sources/JetpackStats/Extensions/Color+Extensions.swift @@ -0,0 +1,43 @@ +import SwiftUI + +extension Color { + /// Converts a Color to its hex string representation + func toHex() -> String { + let uiColor = UIColor(self) + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + + uiColor.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + + return String(format: "#%02X%02X%02X", + Int(red * 255), + Int(green * 255), + Int(blue * 255)) + } + + /// Interpolates between two colors + static func interpolate(from: Color, to: Color, fraction: Double) -> Color { + let fromUIColor = UIColor(from) + let toUIColor = UIColor(to) + + var r1: CGFloat = 0, g1: CGFloat = 0, b1: CGFloat = 0, a1: CGFloat = 0 + var r2: CGFloat = 0, g2: CGFloat = 0, b2: CGFloat = 0, a2: CGFloat = 0 + + fromUIColor.getRed(&r1, green: &g1, blue: &b1, alpha: &a1) + toUIColor.getRed(&r2, green: &g2, blue: &b2, alpha: &a2) + + let r = r1 + (r2 - r1) * CGFloat(fraction) + let g = g1 + (g2 - g1) * CGFloat(fraction) + let b = b1 + (b2 - b1) * CGFloat(fraction) + let a = a1 + (a2 - a1) * CGFloat(fraction) + + return Color(UIColor(red: r, green: g, blue: b, alpha: a)) + } + + /// Lightens the color by mixing it with white + func lightened(by percentage: Double) -> Color { + Color.interpolate(from: self, to: .white, fraction: percentage) + } +} \ No newline at end of file diff --git a/Modules/Sources/JetpackStats/Views/CountriesMapView.swift b/Modules/Sources/JetpackStats/Views/CountriesMapView.swift index 93c2af96158c..e977edcefcb3 100644 --- a/Modules/Sources/JetpackStats/Views/CountriesMapView.swift +++ b/Modules/Sources/JetpackStats/Views/CountriesMapView.swift @@ -9,7 +9,7 @@ struct CountriesMapView: View { svgResourceName: "world-map", data: data.mapDataAsDouble, colorAxis: [ - primaryColor.opacity(0.1), + primaryColor.lightened(by: 0.9), primaryColor ], strokeColor: Color(UIColor.secondarySystemGroupedBackground), diff --git a/Modules/Sources/JetpackStats/Views/InteractiveMapView.swift b/Modules/Sources/JetpackStats/Views/InteractiveMapView.swift index 8d816fb4fe45..9cfa71e71b0d 100644 --- a/Modules/Sources/JetpackStats/Views/InteractiveMapView.swift +++ b/Modules/Sources/JetpackStats/Views/InteractiveMapView.swift @@ -61,7 +61,7 @@ struct InteractiveMapView: View { - + + \(svg) @@ -273,15 +385,20 @@ private func interpolateColor(_ value: Double, colorAxis: [UIColor]) -> UIColor private struct SVGWebView: UIViewRepresentable { let htmlContent: String + @Binding var selectedCountryCode: String? func makeCoordinator() -> Coordinator { - Coordinator() + Coordinator(selectedCountryCode: $selectedCountryCode) } func makeUIView(context: Context) -> WKWebView { let configuration = WKWebViewConfiguration() configuration.preferences.setValue(true, forKey: "allowFileAccessFromFileURLs") + // Add message handlers for JavaScript communication + configuration.userContentController.add(context.coordinator, name: "countrySelected") + configuration.userContentController.add(context.coordinator, name: "countryHovered") + let webView = WKWebView(frame: .zero, configuration: configuration) webView.isOpaque = false webView.backgroundColor = .clear @@ -298,13 +415,31 @@ private struct SVGWebView: UIViewRepresentable { webView.loadHTMLString(htmlContent, baseURL: nil) } - class Coordinator: NSObject, WKNavigationDelegate { + class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler { + @Binding var selectedCountryCode: String? + + init(selectedCountryCode: Binding) { + self._selectedCountryCode = selectedCountryCode + } + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { // Fade in when content is loaded UIView.animate(withDuration: 0.3, delay: 0.05, options: .curveEaseIn) { webView.alpha = 1 } } + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + if message.name == "countrySelected" { + DispatchQueue.main.async { + if let countryCode = message.body as? String { + self.selectedCountryCode = countryCode + } else { + self.selectedCountryCode = nil + } + } + } + } } } @@ -339,7 +474,7 @@ private struct SVGWebView: UIViewRepresentable { "PT": 500, "CZ": 450 ], - configuration: .init(tintColor: Constants.Colors.uiColorBlue) + configuration: .init(tintColor: Constants.Colors.uiColorBlue), selectedCountryCode: .constant(nil) ) .frame(height: 230) .frame(maxWidth: .infinity, maxHeight: .infinity) From 9e523c4ae7c388d1fde82bf8f7529f4010cf338e Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Sat, 26 Jul 2025 16:35:15 -0400 Subject: [PATCH 127/349] Extract interactive-map-template --- .../Resources/interactive-map-template.html | 136 ++++++++++++++++ .../Views/InteractiveMapView.swift | 147 ++---------------- 2 files changed, 145 insertions(+), 138 deletions(-) create mode 100644 Modules/Sources/JetpackStats/Resources/interactive-map-template.html diff --git a/Modules/Sources/JetpackStats/Resources/interactive-map-template.html b/Modules/Sources/JetpackStats/Resources/interactive-map-template.html new file mode 100644 index 000000000000..2d656056c802 --- /dev/null +++ b/Modules/Sources/JetpackStats/Resources/interactive-map-template.html @@ -0,0 +1,136 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/Modules/Sources/JetpackStats/Views/InteractiveMapView.swift b/Modules/Sources/JetpackStats/Views/InteractiveMapView.swift index 8aa11586de3b..0cc086bf0307 100644 --- a/Modules/Sources/JetpackStats/Views/InteractiveMapView.swift +++ b/Modules/Sources/JetpackStats/Views/InteractiveMapView.swift @@ -131,144 +131,15 @@ struct InteractiveMapView: View { } private func wrapSVGInHTML(_ svg: String) -> String { - """ - - - - - - - - - \(svg) - - - """ + // Load HTML template from resources + guard let templatePath = Bundle.module.path(forResource: "interactive-map-template", ofType: "html"), + let template = try? String(contentsOfFile: templatePath) else { + // Fallback to inline HTML if template not found + return "\(svg)" + } + + // Replace placeholder with SVG content + return template.replacingOccurrences(of: "", with: svg) } } From b6c08dda4b60759920e7f910ef44b34e7d74e7c5 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Sat, 26 Jul 2025 16:41:23 -0400 Subject: [PATCH 128/349] Improve tooltip view --- .../Extensions/Color+Extensions.swift | 22 +-- Modules/Sources/JetpackStats/Strings.swift | 8 + .../JetpackStats/Views/CountriesMapView.swift | 88 ++-------- .../JetpackStats/Views/CountryTooltip.swift | 152 ++++++++++++++++++ .../Views/InteractiveMapView.swift | 68 ++++---- 5 files changed, 218 insertions(+), 120 deletions(-) create mode 100644 Modules/Sources/JetpackStats/Views/CountryTooltip.swift diff --git a/Modules/Sources/JetpackStats/Extensions/Color+Extensions.swift b/Modules/Sources/JetpackStats/Extensions/Color+Extensions.swift index ce965d14c9c9..c84171ad54f7 100644 --- a/Modules/Sources/JetpackStats/Extensions/Color+Extensions.swift +++ b/Modules/Sources/JetpackStats/Extensions/Color+Extensions.swift @@ -7,33 +7,33 @@ extension UIColor { var green: CGFloat = 0 var blue: CGFloat = 0 var alpha: CGFloat = 0 - + getRed(&red, green: &green, blue: &blue, alpha: &alpha) - - return String(format: "#%02X%02X%02X", - Int(red * 255), - Int(green * 255), + + return String(format: "#%02X%02X%02X", + Int(red * 255), + Int(green * 255), Int(blue * 255)) } - + /// Interpolates between two colors static func interpolate(from: UIColor, to: UIColor, fraction: Double) -> UIColor { var r1: CGFloat = 0, g1: CGFloat = 0, b1: CGFloat = 0, a1: CGFloat = 0 var r2: CGFloat = 0, g2: CGFloat = 0, b2: CGFloat = 0, a2: CGFloat = 0 - + from.getRed(&r1, green: &g1, blue: &b1, alpha: &a1) to.getRed(&r2, green: &g2, blue: &b2, alpha: &a2) - + let r = r1 + (r2 - r1) * CGFloat(fraction) let g = g1 + (g2 - g1) * CGFloat(fraction) let b = b1 + (b2 - b1) * CGFloat(fraction) let a = a1 + (a2 - a1) * CGFloat(fraction) - + return UIColor(red: r, green: g, blue: b, alpha: a) } - + /// Lightens the color by mixing it with white func lightened(by percentage: Double) -> UIColor { UIColor.interpolate(from: self, to: .white, fraction: percentage) } -} \ No newline at end of file +} diff --git a/Modules/Sources/JetpackStats/Strings.swift b/Modules/Sources/JetpackStats/Strings.swift index 556e162ddbe9..53d47e67852a 100644 --- a/Modules/Sources/JetpackStats/Strings.swift +++ b/Modules/Sources/JetpackStats/Strings.swift @@ -58,6 +58,14 @@ enum Strings { static let videos = AppLocalizedString("jetpackStats.siteDataTypes.videos", value: "Videos", comment: "Videos data type") } + enum Countries { + static let noViews = AppLocalizedString( + "jetpackStats.countries.noViews", + value: "No views", + comment: "Message shown when a country has no views" + ) + } + 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") diff --git a/Modules/Sources/JetpackStats/Views/CountriesMapView.swift b/Modules/Sources/JetpackStats/Views/CountriesMapView.swift index 6a1cd42f2e55..582e832a738d 100644 --- a/Modules/Sources/JetpackStats/Views/CountriesMapView.swift +++ b/Modules/Sources/JetpackStats/Views/CountriesMapView.swift @@ -4,7 +4,7 @@ struct CountriesMapView: View { let data: CountriesMapData let primaryColor: Color @Binding var selectedCountryCode: String? - + var body: some View { InteractiveMapView( data: data.mapDataAsDouble, @@ -20,15 +20,15 @@ struct CountriesMapData { let mapData: [String: NSNumber] let locations: [TopListData.Location] let previousLocations: [String: TopListData.Location] - + var mapDataAsDouble: [String: Double] { mapData.mapValues { $0.doubleValue } } - + func location(for countryCode: String) -> TopListData.Location? { locations.first { $0.countryCode == countryCode } } - + func previousLocation(for countryCode: String) -> TopListData.Location? { previousLocations[countryCode] } @@ -41,7 +41,7 @@ struct CountriesMapData { return (code, location) } ) - + let sortedLocations = locations.sorted { ($0.metrics.views ?? 0) > ($1.metrics.views ?? 0) } self.minViewsCount = sortedLocations.last?.metrics.views ?? 0 @@ -66,21 +66,22 @@ struct CountriesMapContainer: View { var body: some View { VStack(spacing: 12) { // Map View with tooltip overlay - ZStack(alignment: .topLeading) { + ZStack(alignment: .top) { CountriesMapView(data: data, primaryColor: primaryColor, selectedCountryCode: $selectedCountryCode) .frame(height: mapHeight) .cornerRadius(8) - - // Tooltip - if let countryCode = selectedCountryCode, - let location = data.location(for: countryCode) { + + // Tooltip positioned near the top center + if let countryCode = selectedCountryCode { CountryTooltip( - location: location, + countryCode: countryCode, + location: data.location(for: countryCode), previousLocation: data.previousLocation(for: countryCode), primaryColor: primaryColor ) - .padding(8) + .padding(.top, 16) .transition(.opacity) + .animation(.easeInOut(duration: 0.2), value: selectedCountryCode) } } @@ -114,69 +115,6 @@ struct CountriesMapContainer: View { } } -private struct CountryTooltip: View { - let location: TopListData.Location - let previousLocation: TopListData.Location? - let primaryColor: Color - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 6) { - Text(location.flag ?? "") - .font(.title2) - - VStack(alignment: .leading, spacing: 2) { - Text(location.country) - .font(.headline) - .foregroundColor(.primary) - - if let views = location.metrics.views { - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 4) { - Text("Views:") - .font(.caption) - .foregroundColor(.secondary) - Text(views.abbreviatedString()) - .font(.caption) - .foregroundColor(primaryColor) - .fontWeight(.semibold) - } - - if let previousViews = previousLocation?.metrics.views { - HStack(spacing: 4) { - Text("Previous:") - .font(.caption2) - .foregroundColor(.secondary) - Text(previousViews.abbreviatedString()) - .font(.caption2) - .foregroundColor(.secondary) - } - } - } - } - } - } - - if let previousViews = previousLocation?.metrics.views, - let currentViews = location.metrics.views, - previousViews > 0 { - let change = Double(currentViews - previousViews) / Double(previousViews) * 100 - HStack(spacing: 4) { - Image(systemName: change >= 0 ? "arrow.up.right" : "arrow.down.right") - .font(.caption2) - Text("\(abs(Int(change)))%") - .font(.caption) - } - .foregroundColor(change >= 0 ? .green : .red) - } - } - .padding(12) - .background(Color(UIColor.secondarySystemBackground)) - .cornerRadius(8) - .shadow(color: Color.black.opacity(0.1), radius: 4, x: 0, y: 2) - } -} - private extension Int { func abbreviatedString() -> String { let formatter = NumberFormatter() diff --git a/Modules/Sources/JetpackStats/Views/CountryTooltip.swift b/Modules/Sources/JetpackStats/Views/CountryTooltip.swift new file mode 100644 index 000000000000..7a3a0980a1c4 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/CountryTooltip.swift @@ -0,0 +1,152 @@ +import SwiftUI + +struct CountryTooltip: View { + let countryCode: String + let location: TopListData.Location? + let previousLocation: TopListData.Location? + let primaryColor: Color + + private var countryName: String { + if let location { + return location.country + } else { + // Use native API to get country name from code + let locale = Locale.current + return locale.localizedString(forRegionCode: countryCode) ?? countryCode + } + } + + private var countryFlag: String { + if let flag = location?.flag { + return flag + } else { + // Generate flag emoji from country code + let base: UInt32 = 127397 + var s = "" + for scalar in countryCode.uppercased().unicodeScalars { + s.unicodeScalars.append(UnicodeScalar(base + scalar.value)!) + } + return s + } + } + + private var trend: TrendViewModel? { + guard let currentViews = location?.metrics.views, + let previousViews = previousLocation?.metrics.views, + previousViews > 0 else { + return nil + } + return TrendViewModel( + currentValue: currentViews, + previousValue: previousViews, + metric: .views + ) + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + // Country header + HStack(spacing: 6) { + Text(countryFlag) + .font(.title3) + Text(countryName) + .font(.subheadline) + .fontWeight(.semibold) + } + + if let location { + // Current views + CountryTooltipRow( + color: primaryColor, + value: location.metrics.views, + isPrimary: true + ) + + // Previous views + if let previousLocation { + CountryTooltipRow( + color: Color.secondary, + value: previousLocation.metrics.views, + isPrimary: false + ) + } + + // Trend + if let trend { + Text(trend.formattedTrend) + .contentTransition(.numericText()) + .font(.subheadline.weight(.medium)).tracking(-0.33) + .foregroundColor(trend.sentiment.foregroundColor) + } + } else { + // No data available + Text(Strings.Countries.noViews) + .font(.caption) + .foregroundColor(.secondary) + } + } + .fixedSize() + .padding(8) + .background(Color(.systemBackground)) + .cornerRadius(8) + .shadow(color: Color.black.opacity(0.1), radius: 4, x: 0, y: 2) + } +} + +private struct CountryTooltipRow: View { + let color: Color + let value: Int? + let isPrimary: Bool + + var body: some View { + HStack(spacing: 6) { + Circle() + .fill(color) + .frame(width: 8, height: 8) + Text(formattedValue) + .font(.subheadline) + .fontWeight(isPrimary ? .medium : .regular) + .foregroundColor(isPrimary ? .primary : .secondary) + } + } + + private var formattedValue: String { + guard let value else { + return "–" + } + return StatsValueFormatter(metric: .views) + .format(value: value) + } +} + +#Preview { + VStack(spacing: 20) { + // With data + CountryTooltip( + countryCode: "US", + location: TopListData.Location( + country: "United States", + flag: "🇺🇸", + countryCode: "US", + metrics: SiteMetricsSet(views: 15000) + ), + previousLocation: TopListData.Location( + country: "United States", + flag: "🇺🇸", + countryCode: "US", + metrics: SiteMetricsSet(views: 12000) + ), + primaryColor: Color.blue + ) + + // Without data + CountryTooltip( + countryCode: "XX", + location: nil, + previousLocation: nil, + primaryColor: Color.blue + ) + } + .padding() + .background(Color(.secondarySystemBackground)) +} diff --git a/Modules/Sources/JetpackStats/Views/InteractiveMapView.swift b/Modules/Sources/JetpackStats/Views/InteractiveMapView.swift index 0cc086bf0307..3135a059e961 100644 --- a/Modules/Sources/JetpackStats/Views/InteractiveMapView.swift +++ b/Modules/Sources/JetpackStats/Views/InteractiveMapView.swift @@ -38,12 +38,12 @@ struct InteractiveMapView: View { struct Configuration { let lightStyle: MapStyle let darkStyle: MapStyle - + init(lightStyle: MapStyle, darkStyle: MapStyle) { self.lightStyle = lightStyle self.darkStyle = darkStyle } - + init(tintColor: UIColor) { self.lightStyle = MapStyle( colorAxis: [ @@ -63,12 +63,12 @@ struct InteractiveMapView: View { ) } } - + let svgResourceName: String let data: [String: Double] let configuration: Configuration @Binding var selectedCountryCode: String? - + init( svgResourceName: String = "world-map", data: [String: Double], @@ -93,7 +93,7 @@ struct InteractiveMapView: View { private var parameters: Parameters { Parameters(data: data, colorScheme: colorScheme) } - + var body: some View { ZStack { if let processedSVG { @@ -104,16 +104,16 @@ struct InteractiveMapView: View { await updateMap(parameters: parameters) } } - + @MainActor private func updateMap(parameters: Parameters) async { guard let svgContent = await loadSVG(resourceName: svgResourceName) else { return } - + // Get the style for the current color scheme let baseStyle = parameters.colorScheme == .dark ? configuration.darkStyle : configuration.lightStyle - + // Resolve colors in the current trait collection let traitCollection = UITraitCollection(userInterfaceStyle: parameters.colorScheme == .dark ? .dark : .light) let resolvedStyle = MapStyle( @@ -129,7 +129,7 @@ struct InteractiveMapView: View { guard !Task.isCancelled else { return } self.processedSVG = wrapSVGInHTML(processedSVGContent) } - + private func wrapSVGInHTML(_ svg: String) -> String { // Load HTML template from resources guard let templatePath = Bundle.module.path(forResource: "interactive-map-template", ofType: "html"), @@ -137,7 +137,7 @@ struct InteractiveMapView: View { // Fallback to inline HTML if template not found return "\(svg)" } - + // Replace placeholder with SVG content return template.replacingOccurrences(of: "", with: svg) } @@ -169,32 +169,32 @@ private func processSVG( let values = data.values let minValue = values.min() ?? 0 let maxValue = values.max() ?? 1 - + var processedContent = svgContent - + // Process each country in the data for (countryCode, value) in data { let normalizedValue = (value - minValue) / (maxValue - minValue) let color = interpolateColor(normalizedValue, colorAxis: style.colorAxis) - + // Replace fill color for paths with matching country codes processedContent = processCountryInSVG(processedContent, countryCode: countryCode, color: color) } - + // Update default fill color for countries without data processedContent = updateDefaultColors(processedContent, strokeColor: style.strokeColor, fillColor: style.fillColor) - + return processedContent } private func processCountryInSVG(_ svg: String, countryCode: String, color: UIColor) -> String { var result = svg let hexColor = color.toHex() - + // Look for path elements with country code as ID // The SVG uses id="XX" where XX is the 2-letter country code let pattern = "(]*?)(?:fill=\"[^\"]*\")?([^>]*?>)" - + if let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) { let range = NSRange(location: 0, length: result.utf16.count) result = regex.stringByReplacingMatches( @@ -204,24 +204,24 @@ private func processCountryInSVG(_ svg: String, countryCode: String, color: UICo withTemplate: "$1 fill=\"\(hexColor)\" style=\"fill:\(hexColor)\"$2" ) } - + return result } private func updateDefaultColors(_ svg: String, strokeColor: UIColor, fillColor: UIColor) -> String { var result = svg - + // First, update the CSS class that defines default colors let fillHex = fillColor.toHex() let strokeHex = strokeColor.toHex() - + // Replace the .st0 class definition in the style tag result = result.replacingOccurrences( of: "\\.st0\\{[^}]*\\}", with: ".st0{fill:\(fillHex);stroke:\(strokeHex);stroke-width:1.0;}", options: .regularExpression ) - + return result } @@ -230,10 +230,10 @@ private func interpolateColor(_ value: Double, colorAxis: [UIColor]) -> UIColor guard colorAxis.count >= 2 else { return colorAxis.first ?? .blue } - + // Clamp value between 0 and 1 let clampedValue = min(max(value, 0), 1) - + if colorAxis.count == 2 { // Simple interpolation between two colors return UIColor.interpolate(from: colorAxis[0], to: colorAxis[1], fraction: clampedValue) @@ -243,7 +243,7 @@ private func interpolateColor(_ value: Double, colorAxis: [UIColor]) -> UIColor let lowerIndex = Int(scaledValue) let upperIndex = min(lowerIndex + 1, colorAxis.count - 1) let fraction = scaledValue - Double(lowerIndex) - + return UIColor.interpolate( from: colorAxis[lowerIndex], to: colorAxis[upperIndex], @@ -257,19 +257,19 @@ private func interpolateColor(_ value: Double, colorAxis: [UIColor]) -> UIColor private struct SVGWebView: UIViewRepresentable { let htmlContent: String @Binding var selectedCountryCode: String? - + func makeCoordinator() -> Coordinator { Coordinator(selectedCountryCode: $selectedCountryCode) } - + func makeUIView(context: Context) -> WKWebView { let configuration = WKWebViewConfiguration() configuration.preferences.setValue(true, forKey: "allowFileAccessFromFileURLs") - + // Add message handlers for JavaScript communication configuration.userContentController.add(context.coordinator, name: "countrySelected") configuration.userContentController.add(context.coordinator, name: "countryHovered") - + let webView = WKWebView(frame: .zero, configuration: configuration) webView.isOpaque = false webView.backgroundColor = .clear @@ -277,29 +277,29 @@ private struct SVGWebView: UIViewRepresentable { webView.navigationDelegate = context.coordinator webView.alpha = 0 - + return webView } - + func updateUIView(_ webView: WKWebView, context: Context) { // Force reload by clearing cache when color scheme changes webView.loadHTMLString(htmlContent, baseURL: nil) } - + class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler { @Binding var selectedCountryCode: String? - + init(selectedCountryCode: Binding) { self._selectedCountryCode = selectedCountryCode } - + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { // Fade in when content is loaded UIView.animate(withDuration: 0.3, delay: 0.05, options: .curveEaseIn) { webView.alpha = 1 } } - + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { if message.name == "countrySelected" { DispatchQueue.main.async { From e7a34c9811cf1e031c5d094c8bfe5ce209a29ed3 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Sat, 26 Jul 2025 17:01:40 -0400 Subject: [PATCH 129/349] Refactor --- .../JetpackStats/Cards/TopListCard.swift | 10 +- .../Cards/TopListCardViewModel.swift | 19 ++ .../JetpackStats/Views/CountriesMapView.swift | 190 ------------------ .../Views/CountryMap/CountriesMapView.swift | 173 ++++++++++++++++ .../{ => CountryMap}/CountryTooltip.swift | 0 .../{ => CountryMap}/InteractiveMapView.swift | 10 +- .../Views/{ => Heatmap}/HeatmapView.swift | 0 .../{ => Heatmap}/WeeklyTrendsView.swift | 0 .../{ => Heatmap}/YearlyTrendsView.swift | 0 9 files changed, 202 insertions(+), 200 deletions(-) delete mode 100644 Modules/Sources/JetpackStats/Views/CountriesMapView.swift create mode 100644 Modules/Sources/JetpackStats/Views/CountryMap/CountriesMapView.swift rename Modules/Sources/JetpackStats/Views/{ => CountryMap}/CountryTooltip.swift (100%) rename Modules/Sources/JetpackStats/Views/{ => CountryMap}/InteractiveMapView.swift (98%) rename Modules/Sources/JetpackStats/Views/{ => Heatmap}/HeatmapView.swift (100%) rename Modules/Sources/JetpackStats/Views/{ => Heatmap}/WeeklyTrendsView.swift (100%) rename Modules/Sources/JetpackStats/Views/{ => Heatmap}/YearlyTrendsView.swift (100%) diff --git a/Modules/Sources/JetpackStats/Cards/TopListCard.swift b/Modules/Sources/JetpackStats/Cards/TopListCard.swift index 9960cc58a30d..191075788ddb 100644 --- a/Modules/Sources/JetpackStats/Cards/TopListCard.swift +++ b/Modules/Sources/JetpackStats/Cards/TopListCard.swift @@ -17,11 +17,11 @@ struct TopListCard: View { StatsCardTitleView(title: viewModel.selection.item == .locations ? "Countries" : viewModel.title) Spacer(minLength: 44) } - VStack(spacing: 12) { - if viewModel.selection.item == .locations, let data = viewModel.matchedData, !data.items.isEmpty { - CountriesMapContainer( - data: CountriesMapData(locations: data.items.compactMap { $0 as? TopListData.Location }), - primaryColor: Constants.Colors.blue + VStack(spacing: Constants.step2) { + if viewModel.selection.item == .locations { + CountriesMapView( + data: viewModel.cachedCountriesMapData ?? .init(metric: viewModel.selection.metric, locations: []), + primaryColor: Constants.Colors.uiColorBlue ) } headerView diff --git a/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift b/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift index 5bcfaf2defb3..a73796ed631b 100644 --- a/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift +++ b/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift @@ -18,6 +18,7 @@ final class TopListCardViewModel: ObservableObject, TrafficCardViewModel { @Published private(set) var isLoading = true @Published private(set) var loadingError: Error? @Published private(set) var isStale = false + @Published private(set) var cachedCountriesMapData: CountriesMapData? private let service: any StatsServiceProtocol private let fetchLimit: Int @@ -125,6 +126,13 @@ final class TopListCardViewModel: ObservableObject, TrafficCardViewModel { staleTimer?.cancel() isStale = false matchedData = data + + // Update cached CountriesMapData if locations are selected + if selection.item == .locations { + updateCountriesMapDataCache(from: data) + } else { + cachedCountriesMapData = nil + } } catch is CancellationError { return } catch { @@ -209,4 +217,15 @@ final class TopListCardViewModel: ObservableObject, TrafficCardViewModel { return [] } } + + private func updateCountriesMapDataCache(from data: TopListChartData) { + let locations = data.items.compactMap { $0 as? TopListData.Location } + let previousLocations = data.previousItems.compactMapValues { $0 as? TopListData.Location } + + cachedCountriesMapData = CountriesMapData( + metric: selection.metric, + locations: locations, + previousLocations: previousLocations + ) + } } diff --git a/Modules/Sources/JetpackStats/Views/CountriesMapView.swift b/Modules/Sources/JetpackStats/Views/CountriesMapView.swift deleted file mode 100644 index 582e832a738d..000000000000 --- a/Modules/Sources/JetpackStats/Views/CountriesMapView.swift +++ /dev/null @@ -1,190 +0,0 @@ -import SwiftUI - -struct CountriesMapView: View { - let data: CountriesMapData - let primaryColor: Color - @Binding var selectedCountryCode: String? - - var body: some View { - InteractiveMapView( - data: data.mapDataAsDouble, - configuration: InteractiveMapView.Configuration(tintColor: UIColor(primaryColor)), - selectedCountryCode: $selectedCountryCode - ) - } -} - -struct CountriesMapData { - let minViewsCount: Int - let maxViewsCount: Int - let mapData: [String: NSNumber] - let locations: [TopListData.Location] - let previousLocations: [String: TopListData.Location] - - var mapDataAsDouble: [String: Double] { - mapData.mapValues { $0.doubleValue } - } - - func location(for countryCode: String) -> TopListData.Location? { - locations.first { $0.countryCode == countryCode } - } - - func previousLocation(for countryCode: String) -> TopListData.Location? { - previousLocations[countryCode] - } - - init(locations: [TopListData.Location], previousLocations: [TopListData.Location] = []) { - self.locations = locations - self.previousLocations = Dictionary( - uniqueKeysWithValues: previousLocations.compactMap { location in - guard let code = location.countryCode else { return nil } - return (code, location) - } - ) - - let sortedLocations = locations.sorted { ($0.metrics.views ?? 0) > ($1.metrics.views ?? 0) } - - self.minViewsCount = sortedLocations.last?.metrics.views ?? 0 - self.maxViewsCount = sortedLocations.first?.metrics.views ?? 0 - - self.mapData = locations.reduce(into: [String: NSNumber]()) { result, location in - if let countryCode = location.countryCode, - let views = location.metrics.views { - result[countryCode] = NSNumber(value: views) - } - } - } -} - -struct CountriesMapContainer: View { - let data: CountriesMapData - let primaryColor: Color - - @ScaledMetric private var mapHeight = 200 - @State private var selectedCountryCode: String? - - var body: some View { - VStack(spacing: 12) { - // Map View with tooltip overlay - ZStack(alignment: .top) { - CountriesMapView(data: data, primaryColor: primaryColor, selectedCountryCode: $selectedCountryCode) - .frame(height: mapHeight) - .cornerRadius(8) - - // Tooltip positioned near the top center - if let countryCode = selectedCountryCode { - CountryTooltip( - countryCode: countryCode, - location: data.location(for: countryCode), - previousLocation: data.previousLocation(for: countryCode), - primaryColor: primaryColor - ) - .padding(.top, 16) - .transition(.opacity) - .animation(.easeInOut(duration: 0.2), value: selectedCountryCode) - } - } - - // Gradient Legend - HStack(spacing: 0) { - Text(data.minViewsCount.abbreviatedString()) - .font(.footnote) - .foregroundColor(.secondary) - - Spacer() - - LinearGradient( - colors: [primaryColor.opacity(0.1), primaryColor], - startPoint: .leading, - endPoint: .trailing - ) - .frame(height: 10) - .cornerRadius(5) - - Spacer() - - Text(data.maxViewsCount.abbreviatedString()) - .font(.footnote) - .foregroundColor(.secondary) - } - .accessibilityElement(children: .combine) - .accessibilityLabel("Views range from \(data.minViewsCount) to \(data.maxViewsCount)") - } - .accessibilityElement(children: .combine) - .accessibilityLabel("World map showing views by country") - } -} - -private extension Int { - func abbreviatedString() -> String { - let formatter = NumberFormatter() - formatter.maximumFractionDigits = 1 - - if self >= 1_000_000 { - return "\(formatter.string(from: NSNumber(value: Double(self) / 1_000_000)) ?? "0")M" - } else if self >= 1_000 { - return "\(formatter.string(from: NSNumber(value: Double(self) / 1_000)) ?? "0")K" - } else { - return "\(self)" - } - } -} - -#Preview { - CountriesMapContainer( - data: CountriesMapData(locations: [ - TopListData.Location( - country: "United States", - flag: "🇺🇸", - countryCode: "US", - metrics: SiteMetricsSet(views: 10000) - ), - TopListData.Location( - country: "United Kingdom", - flag: "🇬🇧", - countryCode: "GB", - metrics: SiteMetricsSet(views: 4000) - ), - TopListData.Location( - country: "Canada", - flag: "🇨🇦", - countryCode: "CA", - metrics: SiteMetricsSet(views: 2800) - ), - TopListData.Location( - country: "Germany", - flag: "🇩🇪", - countryCode: "DE", - metrics: SiteMetricsSet(views: 2000) - ), - TopListData.Location( - country: "Australia", - flag: "🇦🇺", - countryCode: "AU", - metrics: SiteMetricsSet(views: 1600) - ), - TopListData.Location( - country: "France", - flag: "🇫🇷", - countryCode: "FR", - metrics: SiteMetricsSet(views: 1400) - ), - TopListData.Location( - country: "Japan", - flag: "🇯🇵", - countryCode: "JP", - metrics: SiteMetricsSet(views: 1100) - ), - TopListData.Location( - country: "Netherlands", - flag: "🇳🇱", - countryCode: "NL", - metrics: SiteMetricsSet(views: 800) - ) - ]), - primaryColor: Constants.Colors.blue - ) - .padding() - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color(UIColor(light: .systemBackground, dark: .secondarySystemBackground))) -} diff --git a/Modules/Sources/JetpackStats/Views/CountryMap/CountriesMapView.swift b/Modules/Sources/JetpackStats/Views/CountryMap/CountriesMapView.swift new file mode 100644 index 000000000000..007af3fcc522 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/CountryMap/CountriesMapView.swift @@ -0,0 +1,173 @@ +import SwiftUI + +struct CountriesMapView: View { + let data: CountriesMapData + let primaryColor: UIColor + + private let mapHeight: CGFloat = 240 + @State private var selectedCountryCode: String? + + var body: some View { + VStack(spacing: 12) { + // Map View with tooltip overlay + ZStack(alignment: .top) { + InteractiveMapView( + data: data.mapData, + configuration: .init(tintColor: primaryColor), + selectedCountryCode: $selectedCountryCode + ) + .frame(height: mapHeight) + } + + // Gradient Legend + HStack(spacing: 4) { + Text(formattedValue(data.minViews)) + .font(.footnote) + .foregroundColor(.secondary) + + LinearGradient( + colors: [Color(primaryColor.lightened(by: 0.8)), Color(primaryColor)], + startPoint: .leading, + endPoint: .trailing + ) + .frame(width: 60, height: 8) + .cornerRadius(5) + + Text(formattedValue(data.maxViews)) + .font(.footnote) + .foregroundColor(.secondary) + + Spacer() + } + } + .overlay(alignment: .top) { + // Tooltip positioned near the top center + if let countryCode = selectedCountryCode { + CountryTooltip( + countryCode: countryCode, + location: data.location(for: countryCode), + previousLocation: data.previousLocation(for: countryCode), + primaryColor: Color(primaryColor) + ) + .transition(.opacity) + .padding(.top, -24) + .animation(.easeInOut(duration: 0.2), value: selectedCountryCode) + } + } + } + + private func formattedValue(_ value: Int) -> String { + StatsValueFormatter(metric: data.metric) + .format(value: value, context: .compact) + } +} + +struct CountriesMapData { + let metric: SiteMetric + let minViews: Int + let maxViews: Int + let mapData: [String: Int] + let locations: [TopListData.Location] + let previousLocations: [String: TopListData.Location] + + func location(for countryCode: String) -> TopListData.Location? { + locations.first { $0.countryCode == countryCode } + } + + func previousLocation(for countryCode: String) -> TopListData.Location? { + previousLocations[countryCode] + } + + init( + metric: SiteMetric, + locations: [TopListData.Location], + previousLocations: [TopListItemID: TopListData.Location] = [:] + ) { + self.metric = metric + self.locations = locations + self.previousLocations = { + var output: [String: TopListData.Location] = [:] + for location in previousLocations.values { + if let countryCode = location.countryCode { + output[countryCode] = location + } + } + return output + }() + + let views = locations.compactMap(\.metrics.views) + self.minViews = views.min() ?? 0 + self.maxViews = views.min() ?? 0 + + self.mapData = { + var output: [String: Int] = [:] + for location in locations { + if let countryCode = location.countryCode, + let views = location.metrics.views { + output[countryCode] = views + } + } + return output + }() + } +} + +#Preview { + CountriesMapView( + data: CountriesMapData(metric: .views, locations: [ + TopListData.Location( + country: "United States", + flag: "🇺🇸", + countryCode: "US", + metrics: SiteMetricsSet(views: 10000) + ), + TopListData.Location( + country: "United Kingdom", + flag: "🇬🇧", + countryCode: "GB", + metrics: SiteMetricsSet(views: 4000) + ), + TopListData.Location( + country: "Canada", + flag: "🇨🇦", + countryCode: "CA", + metrics: SiteMetricsSet(views: 2800) + ), + TopListData.Location( + country: "Germany", + flag: "🇩🇪", + countryCode: "DE", + metrics: SiteMetricsSet(views: 2000) + ), + TopListData.Location( + country: "Australia", + flag: "🇦🇺", + countryCode: "AU", + metrics: SiteMetricsSet(views: 1600) + ), + TopListData.Location( + country: "France", + flag: "🇫🇷", + countryCode: "FR", + metrics: SiteMetricsSet(views: 1400) + ), + TopListData.Location( + country: "Japan", + flag: "🇯🇵", + countryCode: "JP", + metrics: SiteMetricsSet(views: 1100) + ), + TopListData.Location( + country: "Netherlands", + flag: "🇳🇱", + countryCode: "NL", + metrics: SiteMetricsSet(views: 800) + ) + ]), + primaryColor: Constants.Colors.uiColorBlue + ) + .padding() + .cardStyle() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(UIColor(light: .secondarySystemBackground, dark: .systemBackground))) +} diff --git a/Modules/Sources/JetpackStats/Views/CountryTooltip.swift b/Modules/Sources/JetpackStats/Views/CountryMap/CountryTooltip.swift similarity index 100% rename from Modules/Sources/JetpackStats/Views/CountryTooltip.swift rename to Modules/Sources/JetpackStats/Views/CountryMap/CountryTooltip.swift diff --git a/Modules/Sources/JetpackStats/Views/InteractiveMapView.swift b/Modules/Sources/JetpackStats/Views/CountryMap/InteractiveMapView.swift similarity index 98% rename from Modules/Sources/JetpackStats/Views/InteractiveMapView.swift rename to Modules/Sources/JetpackStats/Views/CountryMap/InteractiveMapView.swift index 3135a059e961..f058849ae4cf 100644 --- a/Modules/Sources/JetpackStats/Views/InteractiveMapView.swift +++ b/Modules/Sources/JetpackStats/Views/CountryMap/InteractiveMapView.swift @@ -65,13 +65,13 @@ struct InteractiveMapView: View { } let svgResourceName: String - let data: [String: Double] + let data: [String: Int] let configuration: Configuration @Binding var selectedCountryCode: String? init( svgResourceName: String = "world-map", - data: [String: Double], + data: [String: Int], configuration: Configuration, selectedCountryCode: Binding ) { @@ -86,7 +86,7 @@ struct InteractiveMapView: View { @Environment(\.colorScheme) private var colorScheme private struct Parameters: Equatable { - let data: [String: Double] + let data: [String: Int] let colorScheme: ColorScheme } @@ -162,7 +162,7 @@ private func loadSVG(resourceName: String) async -> String? { private func processSVG( svgContent: String, - data: [String: Double], + data: [String: Int], style: MapStyle ) async -> String { // Find min and max values in the data @@ -174,7 +174,7 @@ private func processSVG( // Process each country in the data for (countryCode, value) in data { - let normalizedValue = (value - minValue) / (maxValue - minValue) + let normalizedValue = Double(value - minValue) / Double(maxValue - minValue) let color = interpolateColor(normalizedValue, colorAxis: style.colorAxis) // Replace fill color for paths with matching country codes diff --git a/Modules/Sources/JetpackStats/Views/HeatmapView.swift b/Modules/Sources/JetpackStats/Views/Heatmap/HeatmapView.swift similarity index 100% rename from Modules/Sources/JetpackStats/Views/HeatmapView.swift rename to Modules/Sources/JetpackStats/Views/Heatmap/HeatmapView.swift diff --git a/Modules/Sources/JetpackStats/Views/WeeklyTrendsView.swift b/Modules/Sources/JetpackStats/Views/Heatmap/WeeklyTrendsView.swift similarity index 100% rename from Modules/Sources/JetpackStats/Views/WeeklyTrendsView.swift rename to Modules/Sources/JetpackStats/Views/Heatmap/WeeklyTrendsView.swift diff --git a/Modules/Sources/JetpackStats/Views/YearlyTrendsView.swift b/Modules/Sources/JetpackStats/Views/Heatmap/YearlyTrendsView.swift similarity index 100% rename from Modules/Sources/JetpackStats/Views/YearlyTrendsView.swift rename to Modules/Sources/JetpackStats/Views/Heatmap/YearlyTrendsView.swift From 204545966407eed9a25a3b78ddf9e4dc12acb9fb Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Sat, 26 Jul 2025 17:14:58 -0400 Subject: [PATCH 130/349] Clear map selection when you stop hovering --- .../JetpackStats/Resources/interactive-map-template.html | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Modules/Sources/JetpackStats/Resources/interactive-map-template.html b/Modules/Sources/JetpackStats/Resources/interactive-map-template.html index 2d656056c802..b11e7b8d1e12 100644 --- a/Modules/Sources/JetpackStats/Resources/interactive-map-template.html +++ b/Modules/Sources/JetpackStats/Resources/interactive-map-template.html @@ -101,6 +101,8 @@ if (e.touches.length === 0) { touchActive = false; initialTouchDistance = null; + // Clear selection when finger is lifted + deselectCountry(); } }, { passive: false }); @@ -124,6 +126,11 @@ deselectCountry(); } }); + + // Deselect when mouse button is released + document.addEventListener('mouseup', function(e) { + deselectCountry(); + }); } // Setup interactions when DOM is loaded From e1e2a2049ebcad24f7b7993523f14381552caf72 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Sat, 26 Jul 2025 17:16:28 -0400 Subject: [PATCH 131/349] lintfix --- .../Sources/JetpackStats/Cards/TopListCardViewModel.swift | 6 +++--- .../JetpackStats/Views/CountryMap/CountriesMapView.swift | 2 +- .../JetpackStats/Views/CountryMap/InteractiveMapView.swift | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift b/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift index a73796ed631b..6d1acba5fda6 100644 --- a/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift +++ b/Modules/Sources/JetpackStats/Cards/TopListCardViewModel.swift @@ -126,7 +126,7 @@ final class TopListCardViewModel: ObservableObject, TrafficCardViewModel { staleTimer?.cancel() isStale = false matchedData = data - + // Update cached CountriesMapData if locations are selected if selection.item == .locations { updateCountriesMapDataCache(from: data) @@ -217,11 +217,11 @@ final class TopListCardViewModel: ObservableObject, TrafficCardViewModel { return [] } } - + private func updateCountriesMapDataCache(from data: TopListChartData) { let locations = data.items.compactMap { $0 as? TopListData.Location } let previousLocations = data.previousItems.compactMapValues { $0 as? TopListData.Location } - + cachedCountriesMapData = CountriesMapData( metric: selection.metric, locations: locations, diff --git a/Modules/Sources/JetpackStats/Views/CountryMap/CountriesMapView.swift b/Modules/Sources/JetpackStats/Views/CountryMap/CountriesMapView.swift index 007af3fcc522..753e90f6843d 100644 --- a/Modules/Sources/JetpackStats/Views/CountryMap/CountriesMapView.swift +++ b/Modules/Sources/JetpackStats/Views/CountryMap/CountriesMapView.swift @@ -97,7 +97,7 @@ struct CountriesMapData { let views = locations.compactMap(\.metrics.views) self.minViews = views.min() ?? 0 - self.maxViews = views.min() ?? 0 + self.maxViews = views.max() ?? 0 self.mapData = { var output: [String: Int] = [:] diff --git a/Modules/Sources/JetpackStats/Views/CountryMap/InteractiveMapView.swift b/Modules/Sources/JetpackStats/Views/CountryMap/InteractiveMapView.swift index f058849ae4cf..9bf1450aa6c4 100644 --- a/Modules/Sources/JetpackStats/Views/CountryMap/InteractiveMapView.swift +++ b/Modules/Sources/JetpackStats/Views/CountryMap/InteractiveMapView.swift @@ -174,7 +174,7 @@ private func processSVG( // Process each country in the data for (countryCode, value) in data { - let normalizedValue = Double(value - minValue) / Double(maxValue - minValue) + let normalizedValue = Double(value - minValue) / Double(maxValue - minValue) let color = interpolateColor(normalizedValue, colorAxis: style.colorAxis) // Replace fill color for paths with matching country codes From b9c3a7e3767609a057cc3dca312ea443bc248281 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Sat, 26 Jul 2025 17:18:44 -0400 Subject: [PATCH 132/349] Add TopListCard previews --- .../JetpackStats/Cards/TopListCard.swift | 37 +++++++++++++------ 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/Modules/Sources/JetpackStats/Cards/TopListCard.swift b/Modules/Sources/JetpackStats/Cards/TopListCard.swift index 191075788ddb..0892ae54e414 100644 --- a/Modules/Sources/JetpackStats/Cards/TopListCard.swift +++ b/Modules/Sources/JetpackStats/Cards/TopListCard.swift @@ -193,15 +193,30 @@ struct TopListCard: View { } #Preview { - TopListCard(viewModel: TopListCardViewModel( - selection: .init( - item: .postsAndPages, - metric: .views - ), - dateRange: Calendar.demo.makeDateRange(for: .last28Days), - service: MockStatsService() - )) - .cardStyle() - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Constants.Colors.background) + TopListCardPreview(item: .locations) +} + +private struct TopListCardPreview: View { + let item: TopListItemType + + @StateObject private var viewModel: TopListCardViewModel + + init(item: TopListItemType) { + self.item = item + self._viewModel = StateObject(wrappedValue: TopListCardViewModel( + selection: .init( + item: item, + metric: item == .fileDownloads ? .downloads : .views + ), + dateRange: Calendar.demo.makeDateRange(for: .last28Days), + service: MockStatsService() + )) + } + + var body: some View { + TopListCard(viewModel: viewModel) + .cardStyle() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Constants.Colors.background) + } } From c36539999c2b854d2b782e82167620505d7f57b4 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Sat, 26 Jul 2025 17:31:37 -0400 Subject: [PATCH 133/349] Improve TopListLocationRowView design --- .../JetpackStats/Cards/TopListCard.swift | 6 +++- .../TopList/Rows/TopListLocationRowView.swift | 29 ++++++++----------- 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/Modules/Sources/JetpackStats/Cards/TopListCard.swift b/Modules/Sources/JetpackStats/Cards/TopListCard.swift index 0892ae54e414..34fe822a7613 100644 --- a/Modules/Sources/JetpackStats/Cards/TopListCard.swift +++ b/Modules/Sources/JetpackStats/Cards/TopListCard.swift @@ -188,7 +188,11 @@ struct TopListCard: View { } private var mockData: TopListChartData { - TopListChartData.mock(for: viewModel.selection.item, metric: viewModel.selection.metric, itemCount: itemLimit) + TopListChartData.mock( + for: viewModel.selection.item, + metric: viewModel.selection.metric, + itemCount: itemLimit + ) } } diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListLocationRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListLocationRowView.swift index 137c9bcea2d5..e70ed9b568e1 100644 --- a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListLocationRowView.swift +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListLocationRowView.swift @@ -4,24 +4,19 @@ struct TopListLocationRowView: View { let item: TopListData.Location var body: some View { - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { - if let flag = item.flag { - Text(flag) - .font(.callout) - } - Text(item.country) - .font(.callout) - .foregroundColor(.primary) - .lineLimit(1) - } - - if let countryCode = item.countryCode { - Text(countryCode) - .font(.caption) - .foregroundColor(.secondary) - .lineLimit(1) + HStack(spacing: Constants.step2 / 2) { + if let flag = item.flag { + Text(flag) + .font(.title2) + } else { + Image(systemName: "map") + .font(.body) + .foregroundStyle(.secondary) } + Text(item.country) + .font(.body) + .foregroundColor(.primary) } + .lineLimit(1) } } From c4c4982c97417ac69f59e7d3d8035b18350c5cba Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Sat, 26 Jul 2025 17:34:36 -0400 Subject: [PATCH 134/349] Cleanup isNavigationDisabled --- Modules/Sources/JetpackStats/Screens/AuthorStatsView.swift | 1 - Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift | 4 +++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Modules/Sources/JetpackStats/Screens/AuthorStatsView.swift b/Modules/Sources/JetpackStats/Screens/AuthorStatsView.swift index 13984556bb3f..7865ada82a0a 100644 --- a/Modules/Sources/JetpackStats/Screens/AuthorStatsView.swift +++ b/Modules/Sources/JetpackStats/Screens/AuthorStatsView.swift @@ -24,7 +24,6 @@ struct AuthorStatsView: View { dateRange: range, service: context.service, items: [.postsAndPages], - fetchLimit: 32, filter: .author(userId: author.userId) )) } diff --git a/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift b/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift index 865c9306d029..cedda5991cd7 100644 --- a/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift +++ b/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift @@ -151,7 +151,9 @@ struct ReferrerStatsView: View { ) } - .padding(Constants.step2) + .padding(.vertical, Constants.step2) + .padding(.horizontal, Constants.step3) + .cardStyle() } private var childrenChartData: TopListChartData { From 42d04ec6b1d19fda488359e48b673a9733f3727f Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Sat, 26 Jul 2025 17:36:48 -0400 Subject: [PATCH 135/349] Remove map dependency --- Modules/Package.swift | 1 - Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift | 1 - 2 files changed, 2 deletions(-) diff --git a/Modules/Package.swift b/Modules/Package.swift index c21c87bba187..7efc5411c3df 100644 --- a/Modules/Package.swift +++ b/Modules/Package.swift @@ -99,7 +99,6 @@ let package = Package( dependencies: [ "WordPressUI", .product(name: "WordPressKit", package: "WordPressKit-iOS"), - .product(name: "FSInteractiveMap", package: "FSInteractiveMap"), ], resources: [.process("Resources")] ), diff --git a/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift b/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift index cedda5991cd7..8e5c14e177f3 100644 --- a/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift +++ b/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift @@ -149,7 +149,6 @@ struct ReferrerStatsView: View { dateRange: dateRange, isNavigationDisabled: true ) - } .padding(.vertical, Constants.step2) .padding(.horizontal, Constants.step3) From cbb53343f8577eace84e07f756974795d1051580 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Sat, 26 Jul 2025 19:32:51 -0400 Subject: [PATCH 136/349] Fix animation in TrafficTabView --- .../JetpackStats/Screens/TrafficTabView.swift | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/Modules/Sources/JetpackStats/Screens/TrafficTabView.swift b/Modules/Sources/JetpackStats/Screens/TrafficTabView.swift index a41c60ac461b..f9cd2fb9847b 100644 --- a/Modules/Sources/JetpackStats/Screens/TrafficTabView.swift +++ b/Modules/Sources/JetpackStats/Screens/TrafficTabView.swift @@ -12,15 +12,13 @@ struct TrafficTabView: View { } var body: some View { - List { - ForEach(viewModels, id: \.id) { viewModel in - makeItem(for: viewModel) - .padding(.vertical, Constants.step1) - .padding(.top, viewModel.id == viewModels.first?.id ? 8 : 0) + ScrollView { + LazyVStack(spacing: Constants.step3) { + ForEach(viewModels, id: \.id) { viewModel in + makeItem(for: viewModel) + } } - .listRowSeparator(.hidden) - .listRowBackground(Color.clear) - .listRowInsets(.zero) + .padding(.vertical, Constants.step2) } .listStyle(.plain) .onAppear { From 1d706d63a2c98d054d1a461b5b1cd06bbcb6336c Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Sat, 26 Jul 2025 19:34:22 -0400 Subject: [PATCH 137/349] Disable zoom in InteractiveMapView --- .../JetpackStats/Resources/interactive-map-template.html | 8 +++++--- .../Views/CountryMap/InteractiveMapView.swift | 5 +++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/Modules/Sources/JetpackStats/Resources/interactive-map-template.html b/Modules/Sources/JetpackStats/Resources/interactive-map-template.html index b11e7b8d1e12..ab13b1033644 100644 --- a/Modules/Sources/JetpackStats/Resources/interactive-map-template.html +++ b/Modules/Sources/JetpackStats/Resources/interactive-map-template.html @@ -1,7 +1,7 @@ - +