diff --git a/Modules/Package.resolved b/Modules/Package.resolved index c3f7539ce334..c5c459ee1f70 100644 --- a/Modules/Package.resolved +++ b/Modules/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "d755e1afd7f7c7e6ce1c005aa1657876c10cdf60de188a23262ab5de91e5e6c3", + "originHash" : "94d091cc524b2f58bb430f21396f16dc040d3bcd7183a98eb196356139a4de86", "pins" : [ { "identity" : "alamofire", @@ -381,8 +381,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/wordpress-mobile/WordPressKit-iOS", "state" : { - "branch" : "trunk", - "revision" : "2b7d4f6acf2641b671c66b20873f5935f22210ed" + "revision" : "440d94e3a3d6f9f39035a371984e088a2fb42a32" } }, { diff --git a/Modules/Package.swift b/Modules/Package.swift index b0bacdfe468c..5b69b85dc041 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: "2b7d4f6acf2641b671c66b20873f5935f22210ed" + revision: "440d94e3a3d6f9f39035a371984e088a2fb42a32" ), .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/Analytics/MockStatsTracker.swift b/Modules/Sources/JetpackStats/Analytics/MockStatsTracker.swift new file mode 100644 index 000000000000..83a64ee68e94 --- /dev/null +++ b/Modules/Sources/JetpackStats/Analytics/MockStatsTracker.swift @@ -0,0 +1,15 @@ +import Foundation + +/// A no-op implementation of StatsTracker for testing and development +final class MockStatsTracker: StatsTracker, Sendable { + static let shared = MockStatsTracker() + + private init() {} + + func send(_ event: StatsEvent, properties: [String: String]) { +#if DEBUG + // In debug builds, print events to console for debugging + debugPrint("[StatsTracker] Event: \(event) \(properties)") +#endif + } +} diff --git a/Modules/Sources/JetpackStats/Analytics/StatsEvent.swift b/Modules/Sources/JetpackStats/Analytics/StatsEvent.swift new file mode 100644 index 000000000000..b1178f844579 --- /dev/null +++ b/Modules/Sources/JetpackStats/Analytics/StatsEvent.swift @@ -0,0 +1,191 @@ +import Foundation + +/// Analytics events for tracking user interactions within the Stats module +/// +/// IMPORTANT: Do not include personally identifiable information (PII) in analytics events. +/// This includes but is not limited to: +/// - User IDs, author IDs, or any unique identifiers +/// - Email addresses +/// - URLs that might contain sensitive information +/// - Post IDs or content identifiers +/// +/// Instead, track only: +/// - Event types and categories +/// - Navigation sources +/// - UI states and configurations +/// - Aggregated metrics +public enum StatsEvent { + // MARK: - Screen View Events + + /// Main stats screen shown + case statsMainScreenShown + + /// Traffic tab shown + case trafficTabShown + + /// Realtime tab shown + case realtimeTabShown + + /// Subscribers tab shown + case subscribersTabShown + + /// Post details screen shown + case postDetailsScreenShown + + /// Author stats screen shown + case authorStatsScreenShown + + /// Archive stats screen shown + case archiveStatsScreenShown + + /// External link stats screen shown + case externalLinkStatsScreenShown + + /// Referrer stats screen shown + case referrerStatsScreenShown + + // MARK: - Date Range Events + + /// Date range preset selected + /// - Parameters: + /// - "selected_preset": The preset selected (e.g., "last_7_days", "last_28_days", "last_90_days", "last_365_days") + case dateRangePresetSelected + + /// Custom date range selected + /// - Parameters: + /// - "start_date": Start date in ISO format + /// - "end_date": End date in ISO format + case customDateRangeSelected + + // MARK: - Card Events + + /// Card shown on screen + /// - Parameters: + /// - "card_type": Type of card (e.g., "chart", "top_list") + /// - "configuration": Card configuration details (e.g., metrics, item type) + case cardShown + + /// Card added to dashboard + /// - Parameters: + /// - "card_type": Type of card (e.g., "chart", "top_list") + case cardAdded + + /// Card removed from dashboard + /// - Parameters: + /// - "card_type": Type of card + case cardRemoved + + // MARK: - Chart Events + + /// Chart type changed + /// - Parameters: + /// - "from_type": Previous chart type (e.g., "line", "bar") + /// - "to_type": New chart type + case chartTypeChanged + + /// Chart metric selected + /// - Parameters: + /// - "metric": The metric selected (e.g., "visitors", "views", "likes") + case chartMetricSelected + + // MARK: - List Events + + /// Top list item tapped + /// - Parameters: + /// - "item_type": Type of item (e.g., "posts_and_pages", "authors", "locations", "referrers") + /// - "metric": The metric being sorted by + case topListItemTapped + + // MARK: - Navigation Events + + /// Stats tab selected + /// - Parameters: + /// - "tab_name": Name of the tab selected + /// - "previous_tab": Name of the previous tab + case statsTabSelected + + // MARK: - Error Events + + /// Error encountered + /// - Parameters: + /// - "error_type": Type of error (e.g., "network", "parsing", "permission") + /// - "error_code": Specific error code if available + /// - "screen": Where the error occurred + case errorEncountered +} + +// MARK: - StatsTracker Protocol + +/// Protocol for tracking analytics events in the Stats module +public protocol StatsTracker: Sendable { + /// Send an analytics event + /// - Parameters: + /// - event: The event to track + /// - properties: Additional properties for the event + func send(_ event: StatsEvent, properties: [String: String]) +} + +// MARK: - StatsTracker Convenience + +extension StatsTracker { + /// Convenience method to send events without properties + func send(_ event: StatsEvent) { + send(event, properties: [:]) + } +} + +// MARK: - Private Extensions + +extension DateIntervalPreset { + /// Analytics tracking name for the preset + var analyticsName: String { + switch self { + case .today: "today" + case .thisWeek: "this_week" + case .thisMonth: "this_month" + case .thisQuarter: "this_quarter" + case .thisYear: "this_year" + case .last7Days: "last_7_days" + case .last28Days: "last_28_days" + case .last30Days: "last_30_days" + case .last90Days: "last_90_days" + case .last6Months: "last_6_months" + case .last12Months: "last_12_months" + case .last3Years: "last_3_years" + case .last10Years: "last_10_years" + } + } +} + +extension TopListItemType { + /// Analytics tracking name for the item type + var analyticsName: String { + switch self { + case .postsAndPages: "posts_and_pages" + case .authors: "authors" + case .referrers: "referrers" + case .locations: "locations" + case .videos: "videos" + case .externalLinks: "external_links" + case .searchTerms: "search_terms" + case .fileDownloads: "file_downloads" + case .archive: "archive" + } + } +} + +extension SiteMetric { + /// Analytics tracking name for the metric + var analyticsName: String { + switch self { + case .views: "views" + case .visitors: "visitors" + case .likes: "likes" + case .comments: "comments" + case .posts: "posts" + case .timeOnSite: "time_on_site" + case .bounceRate: "bounce_rate" + case .downloads: "downloads" + } + } +} diff --git a/Modules/Sources/JetpackStats/Analytics/StatsTracker+ErrorTracking.swift b/Modules/Sources/JetpackStats/Analytics/StatsTracker+ErrorTracking.swift new file mode 100644 index 000000000000..4b17ddd13cb5 --- /dev/null +++ b/Modules/Sources/JetpackStats/Analytics/StatsTracker+ErrorTracking.swift @@ -0,0 +1,55 @@ +import Foundation + +extension StatsTracker { + /// Convenience method to track errors with automatic type detection + /// - Parameters: + /// - error: The error to track + /// - screen: The screen where the error occurred + func trackError(_ error: Error, screen: String) { + let errorType: String + let errorCode = (error as NSError).code + + // Determine error type based on the error instance + switch error { + case let urlError as URLError: + errorType = urlErrorType(urlError) + case is DecodingError: + errorType = "parsing" + case is CancellationError: + return + default: + // Check for common error domains + let nsError = error as NSError + switch nsError.domain { + case NSCocoaErrorDomain: + errorType = "cocoa_\(errorCode)" + case NSURLErrorDomain: + errorType = "url_\(errorCode)" + default: + errorType = "unknown" + } + } + + send(.errorEncountered, properties: [ + "error_type": errorType, + "error_code": "\(errorCode)", + "screen": screen + ]) + } + + /// Determine specific network error type + private func urlErrorType(_ error: URLError) -> String { + switch error.code { + case .notConnectedToInternet: "network_offline" + case .timedOut: "network_timeout" + case .cannotFindHost, .cannotConnectToHost: "network_host_unreachable" + case .networkConnectionLost: "network_connection_lost" + case .dnsLookupFailed: "network_dns_failed" + case .httpTooManyRedirects: "network_too_many_redirects" + case .resourceUnavailable: "network_resource_unavailable" + case .dataNotAllowed: "network_data_not_allowed" + case .secureConnectionFailed: "network_ssl_failed" + default: "other" + } + } +} diff --git a/Modules/Sources/JetpackStats/Cards/CardConfigurationDelegate.swift b/Modules/Sources/JetpackStats/Cards/CardConfigurationDelegate.swift new file mode 100644 index 000000000000..ff457598f905 --- /dev/null +++ b/Modules/Sources/JetpackStats/Cards/CardConfigurationDelegate.swift @@ -0,0 +1,15 @@ +import Foundation + +enum MoveDirection { + case up + case down + case top + case bottom +} + +@MainActor +protocol CardConfigurationDelegate: AnyObject { + func saveConfiguration(for card: any TrafficCardViewModel) + func deleteCard(_ card: any TrafficCardViewModel) + func moveCard(_ card: any TrafficCardViewModel, direction: MoveDirection) +} diff --git a/Modules/Sources/JetpackStats/Cards/CardViewModel.swift b/Modules/Sources/JetpackStats/Cards/CardViewModel.swift new file mode 100644 index 000000000000..5539cba03c44 --- /dev/null +++ b/Modules/Sources/JetpackStats/Cards/CardViewModel.swift @@ -0,0 +1,9 @@ +import Foundation + +@MainActor +protocol TrafficCardViewModel: AnyObject { + var id: UUID { get } + var dateRange: StatsDateRange { get set } + var isEditing: Bool { get set } + var configurationDelegate: CardConfigurationDelegate? { get set } +} diff --git a/Modules/Sources/JetpackStats/Cards/ChartCard.swift b/Modules/Sources/JetpackStats/Cards/ChartCard.swift new file mode 100644 index 000000000000..5d6824cce3c9 --- /dev/null +++ b/Modules/Sources/JetpackStats/Cards/ChartCard.swift @@ -0,0 +1,296 @@ +import SwiftUI +import Charts + +struct ChartCard: View { + @ObservedObject private var viewModel: ChartCardViewModel + + private var dateRange: StatsDateRange { viewModel.dateRange } + private var metrics: [SiteMetric] { viewModel.metrics } + private var selectedMetric: SiteMetric { viewModel.selectedMetric } + private var selectedChartType: ChartType { viewModel.selectedChartType } + + @State private var isShowingRawData = false + + @ScaledMetric(relativeTo: .largeTitle) private var chartHeight = 180 + + init(viewModel: ChartCardViewModel) { + self.viewModel = viewModel + } + + var body: some View { + VStack(spacing: 0) { + VStack(spacing: Constants.step1) { + headerView(for: selectedMetric) + .unredacted() + contentView + } + .padding(.vertical, Constants.step2) + .padding(.horizontal, Constants.step3) + + if metrics.count > 1 { + Divider() + cardFooterView + } + } + .dynamicTypeSize(...DynamicTypeSize.accessibility1) + .onAppear { + viewModel.onAppear() + } + .overlay(alignment: .topTrailing) { + moreMenu + } + .cardStyle() + .grayscale(viewModel.isStale ? 1 : 0) + .opacity(viewModel.isEditing ? 0.6 : 1) + .scaleEffect(viewModel.isEditing ? 0.95 : 1) + .animation(.smooth, value: viewModel.isStale) + .animation(.spring, value: viewModel.isEditing) + .accessibilityElement(children: .contain) + .accessibilityLabel(Strings.Accessibility.chartContainer) + .sheet(isPresented: $viewModel.isEditing) { + NavigationStack { + ChartCardCustomizationView(chartViewModel: viewModel) + .navigationTitle(Strings.AddChart.selectMetric) + .navigationBarTitleDisplayMode(.inline) + } + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } + .sheet(isPresented: $isShowingRawData) { + if let data = viewModel.chartData[selectedMetric] { + NavigationStack { + ChartDataListView(data: data, dateRange: dateRange) + } + } + } + } + + private func headerView(for metric: SiteMetric) -> some View { + HStack { + StatsCardTitleView(title: metric.localizedTitle, showChevron: false) + Spacer(minLength: 44) + } + .accessibilityElement(children: .combine) + .accessibilityLabel(Strings.Accessibility.cardTitle(metric.localizedTitle)) + } + + @ViewBuilder + private var contentView: some View { + VStack(spacing: Constants.step0_5) { + chartHeaderView + .padding(.trailing, -Constants.step0_5) + chartContentView + } + .animation(.spring, value: selectedMetric) + .animation(.spring, value: selectedChartType) + .animation(.easeInOut, value: viewModel.isFirstLoad) + } + + private var chartHeaderView: some View { + // Showing currently selected (not loaded period) by design + HStack(alignment: .center, spacing: 0) { + if let data = viewModel.chartData[selectedMetric] { + ChartValuesSummaryView( + trend: .make(data, context: .regular), + style: .compact + ) + } else if viewModel.isFirstLoad { + ChartValuesSummaryView( + trend: .init(currentValue: 100, previousValue: 10, metric: .views), + style: .compact + ) + .redacted(reason: .placeholder) + } + + Spacer(minLength: 8) + + ChartLegendView( + metric: selectedMetric, + currentPeriod: dateRange.dateInterval, + previousPeriod: dateRange.effectiveComparisonInterval + ) + } + .dynamicTypeSize(...DynamicTypeSize.xxLarge) + } + + @ViewBuilder + private var chartContentView: some View { + if viewModel.isFirstLoad { + mainChartView(metric: selectedMetric, data: mockChartData) + .redacted(reason: .placeholder) + .opacity(0.2) + .pulsating() + } else if let data = viewModel.chartData[selectedMetric] { + if data.isEmpty, data.granularity == .hour { + loadingErrorView(with: Strings.Chart.hourlyDataUnavailable) + } else { + mainChartView(metric: selectedMetric, data: data) + .transition(.opacity.combined(with: .scale(scale: 0.97))) + } + } else { + loadingErrorView(with: viewModel.loadingError?.localizedDescription ?? Strings.Errors.generic) + } + } + + private var cardFooterView: some View { + MetricsOverviewTabView( + data: viewModel.isFirstLoad ? viewModel.placeholderTabViewData : viewModel.tabViewData, + selectedMetric: $viewModel.selectedMetric, + onMetricSelected: { metric in + viewModel.tracker?.send(.chartMetricSelected, properties: [ + "metric": metric.analyticsName + ]) + } + ) + .redacted(reason: viewModel.isFirstLoad ? .placeholder : []) + .pulsating(viewModel.isFirstLoad) + .background(CardGradientBackground(metric: selectedMetric)) + } + + 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 { + 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(.system(size: 17)) + .foregroundColor(.secondary) + .frame(width: 56, height: 50) + } + .tint(Color.primary) + } + + @ViewBuilder + private var moreMenuContent: some View { + Section { + ControlGroup { + ForEach(ChartType.allCases, id: \.self) { type in + Button { + let previousType = viewModel.selectedChartType + viewModel.selectedChartType = type + + // Track chart type change + viewModel.tracker?.send(.chartTypeChanged, properties: [ + "from_type": previousType.rawValue, + "to_type": type.rawValue + ]) + } label: { + Label(type.localizedTitle, systemImage: type.systemImage) + } + } + } + } + Section { + Button { + isShowingRawData = true + } label: { + Label(Strings.Chart.showData, systemImage: "tablecells") + } + Link(destination: URL(string: "https://wordpress.com/support/stats/understand-your-sites-traffic/")!) { + Label(Strings.Buttons.learnMore, systemImage: "info.circle") + } + } + EditCardMenuContent(cardViewModel: viewModel) + } + + // MARK: - Chart View + + @ViewBuilder + private func mainChartView(metric: SiteMetric, data: ChartData) -> some View { + VStack(alignment: .leading, spacing: Constants.step1 / 2) { + chartContentView(data: data) + .frame(height: chartHeight) + .padding(.horizontal, -Constants.step1) + .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 struct CardGradientBackground: View { + let metric: SiteMetric + + @Environment(\.colorScheme) var colorScheme + + var body: some View { + LinearGradient( + colors: [ + metric.primaryColor.opacity(colorScheme == .light ? 0.03 : 0.04), + Constants.Colors.secondaryBackground + ], + startPoint: .top, + endPoint: .center + ) + } +} + +enum ChartType: String, CaseIterable, Codable { + 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 + +private struct ChartCardPreview: View { + @StateObject var viewModel = ChartCardViewModel( + configuration: ChartCardConfiguration( + metrics: [.views, .visitors, .likes, .comments] + ), + dateRange: Calendar.demo.makeDateRange(for: .today), + service: MockStatsService(), + tracker: MockStatsTracker.shared + ) + + var body: some View { + ChartCard(viewModel: viewModel) + .cardStyle() + } +} + +#Preview { + ScrollView { + VStack(spacing: 20) { + ChartCardPreview() + } + .padding(.vertical) + } + .background(Color(.systemGroupedBackground)) +} diff --git a/Modules/Sources/JetpackStats/Cards/ChartCardConfiguration.swift b/Modules/Sources/JetpackStats/Cards/ChartCardConfiguration.swift new file mode 100644 index 000000000000..b6dd1b9e1b2c --- /dev/null +++ b/Modules/Sources/JetpackStats/Cards/ChartCardConfiguration.swift @@ -0,0 +1,13 @@ +import Foundation + +struct ChartCardConfiguration: Codable { + let id: UUID + var metrics: [SiteMetric] + var chartType: ChartType + + init(id: UUID = UUID(), metrics: [SiteMetric], chartType: ChartType = .line) { + self.id = id + self.metrics = metrics + self.chartType = chartType + } +} diff --git a/Modules/Sources/JetpackStats/Cards/ChartCardViewModel.swift b/Modules/Sources/JetpackStats/Cards/ChartCardViewModel.swift new file mode 100644 index 000000000000..c67bd726c226 --- /dev/null +++ b/Modules/Sources/JetpackStats/Cards/ChartCardViewModel.swift @@ -0,0 +1,209 @@ +import SwiftUI + +@MainActor +final class ChartCardViewModel: ObservableObject, TrafficCardViewModel { + var id: UUID { configuration.id } + var metrics: [SiteMetric] { configuration.metrics } + + @Published private(set) var configuration: ChartCardConfiguration + @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 + + @Published var isEditing = false + @Published var selectedMetric: SiteMetric + @Published var selectedChartType: ChartType { + didSet { + // Update configuration when chart type changes + configuration.chartType = selectedChartType + configurationDelegate?.saveConfiguration(for: self) + } + } + + weak var configurationDelegate: CardConfigurationDelegate? + + var dateRange: StatsDateRange { + didSet { + loadData(for: dateRange) + } + } + + private let service: any StatsServiceProtocol + let tracker: (any StatsTracker)? + + private var loadingTask: Task? + private var loadRequestCount = 0 + private var staleTimer: Task? + private var isFirstAppear = true + + var isFirstLoad: Bool { isLoading && chartData.isEmpty } + + init( + configuration: ChartCardConfiguration, + dateRange: StatsDateRange, + service: any StatsServiceProtocol, + tracker: (any StatsTracker)? = nil + ) { + self.configuration = configuration + self.selectedMetric = configuration.metrics.first ?? .views + self.selectedChartType = configuration.chartType + self.dateRange = dateRange + self.service = service + self.tracker = tracker + } + + func updateConfiguration(_ newConfiguration: ChartCardConfiguration) { + self.configuration = newConfiguration + + // Update selectedMetric if it's no longer available in the new configuration + if !newConfiguration.metrics.contains(selectedMetric) { + selectedMetric = newConfiguration.metrics.first ?? .views + } + + // Update chart type from configuration (without triggering didSet) + if selectedChartType != newConfiguration.chartType { + selectedChartType = newConfiguration.chartType + } + + configurationDelegate?.saveConfiguration(for: self) + } + + func onAppear() { + guard isFirstAppear else { return } + isFirstAppear = false + + // Track card shown event + tracker?.send(.cardShown, properties: [ + "card_type": "chart", + "configuration": metrics.map { $0.analyticsName }.joined(separator: "_"), + "chart_type": selectedChartType.rawValue + ]) + + loadData(for: dateRange) + } + + private 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(2)) + 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 + tracker?.trackError(error, screen: "chart_card") + } + + 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 so they + // are displayed on the same timeline on the charts. + let mappedPreviousDataPoints = DataPoint.mapDataPoints( + previousDataPoints, + from: dateRange.effectiveComparisonInterval, + to: dateRange.dateInterval, + component: dateRange.component, + calendar: dateRange.calendar + ) + + output[metric] = ChartData( + metric: metric, + granularity: granularity, + currentTotal: currentResponse.total[metric] ?? 0, + currentData: dataPoints, + previousTotal: previousResponse.total[metric] ?? 0, + 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..d36700915771 --- /dev/null +++ b/Modules/Sources/JetpackStats/Cards/RealtimeMetricsCard.swift @@ -0,0 +1,128 @@ +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 + + @ScaledMetric(relativeTo: .caption) private var pulseCircleSize: CGFloat = 6 + + 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: pulseCircleSize, height: pulseCircleSize) + .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)) + ) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(SiteMetric.views.localizedTitle), \(viewsLast30Min.formatted())") + + realtimeStatRow( + systemImage: SiteMetric.visitors.systemImage, + label: SiteMetric.visitors.localizedTitle, + value: visitorsLast30Min.formatted(.number.notation(.compactName)) + ) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(SiteMetric.visitors.localizedTitle), \(visitorsLast30Min.formatted())") + + realtimeStatRow( + systemImage: SiteMetric.visitors.systemImage, + label: Strings.SiteMetrics.visitorsNow, + value: activeVisitors.formatted(.number.notation(.compactName)) + ) + .accessibilityElement(children: .combine) + .accessibilityLabel(Strings.Accessibility.visitorsNow(activeVisitors)) + } + } + .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) + .accessibilityHidden(true) + + 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(maxWidth: .infinity, 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.background) +} diff --git a/Modules/Sources/JetpackStats/Cards/RealtimeTopListCard.swift b/Modules/Sources/JetpackStats/Cards/RealtimeTopListCard.swift new file mode 100644 index 000000000000..c465709152e0 --- /dev/null +++ b/Modules/Sources/JetpackStats/Cards/RealtimeTopListCard.swift @@ -0,0 +1,164 @@ +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() + } + .padding(.horizontal, Constants.step2) + + VStack(spacing: 12) { + headerView + .padding(.horizontal, Constants.step2) + .unredacted() + contentView + } + } + .padding(.vertical, Constants.step3) + .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) + .opacity(0.1) + .overlay { + SimpleErrorView(error: error) + } + } + } + .animation(.spring, value: selectedItem) + } + + private func topListItemsView(data: TopListResponse) -> some View { + let chartData = TopListData( + item: selectedItem, + metric: .views, + items: data.items, + previousItems: [:] // No previous data for realtime + ) + + return TopListItemsView( + data: chartData, + itemLimit: 6, + dateRange: context.calendar.makeDateRange(for: .today) + ) + } + + private var loadingView: some View { + topListItemsView(data: mockData) + } + + private var mockData: TopListResponse { + let chartData = TopListData.mock( + for: selectedItem, + metric: .views, + itemCount: 6 + ) + return TopListResponse(items: chartData.items) + } + +} + +// MARK: - Preview + +#Preview { + ScrollView { + VStack(spacing: 20) { + // Posts & Pages + RealtimeTopListCard( + availableDataTypes: [.postsAndPages], + 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..0a131d94a87a --- /dev/null +++ b/Modules/Sources/JetpackStats/Cards/RealtimeTopListCardViewModel.swift @@ -0,0 +1,60 @@ +import SwiftUI + +@MainActor +final class RealtimeTopListCardViewModel: ObservableObject { + @Published var topListData: TopListResponse? + @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 + } + + 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/StandaloneChartCard.swift b/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift new file mode 100644 index 000000000000..c687f313e72c --- /dev/null +++ b/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift @@ -0,0 +1,336 @@ +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 + + private let configuration: Configuration + + @State private var dateRange: StatsDateRange + @Binding var chartType: ChartType + @State private var isShowingDatePicker = false + @State private var chartData: ChartData? + + @ScaledMetric(relativeTo: .largeTitle) private var chartHeight = 180 + + @Environment(\.context) private var context + + @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 + /// - chartType: Binding to the chart type + init( + dataPoints: [DataPoint], + metric: SiteMetric, + initialDateRange: StatsDateRange, + chartType: Binding, + configuration: Configuration = .init() + ) { + self.dataPoints = dataPoints + self.metric = metric + self._dateRange = State(initialValue: initialDateRange) + self._chartType = chartType + self.configuration = configuration + } + + var body: some View { + VStack(spacing: Constants.step1) { + StatsCardTitleView(title: metric.localizedTitle) + .frame(maxWidth: .infinity, alignment: .leading) + chartHeaderView + .padding(.trailing, -Constants.step0_5) + chartContentView + .padding(.horizontal, -Constants.step1) + dateRangeControls + .dynamicTypeSize(...DynamicTypeSize.xLarge) + } + .padding(.vertical, Constants.step2) + .padding(.horizontal, Constants.step3) + .dynamicTypeSize(...DynamicTypeSize.xxLarge) + .overlay(alignment: .topTrailing) { + moreMenu + } + .sheet(isPresented: $isShowingDatePicker) { + CustomDateRangePicker(dateRange: $dateRange) + } + .task(id: dateRange) { + await refreshChartData() + } + } + + private var chartHeaderView: some View { + // Showing currently selected (not loaded period) by design + HStack(alignment: .firstTextBaseline, spacing: 0) { + if let data = chartData { + ChartValuesSummaryView( + trend: .make(data, context: .regular), + style: .compact + ) + } else { + ChartValuesSummaryView( + trend: .init(currentValue: 100, previousValue: 10, metric: .views), + style: .compact + ) + .redacted(reason: .placeholder) + } + + Spacer(minLength: 8) + + ChartLegendView( + metric: metric, + currentPeriod: dateRange.dateInterval, + previousPeriod: dateRange.effectiveComparisonInterval + ) + } + } + + private var chartContentView: some View { + Group { + if dateRange.dateInterval.preferredGranularity < configuration.minimumGranularity { + loadingErrorView(with: Strings.Chart.hourlyDataUnavailable) + } else if let chartData { + if chartData.isEmptyOrZero { + loadingErrorView(with: Strings.Chart.empty) + } else { + 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 chartType { + case .line: + LineChartView(data: chartData) + case .columns: + 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 else { + return TrendViewModel(currentValue: 0, previousValue: 0, metric: metric) + } + return TrendViewModel( + currentValue: chartData.currentTotal, + previousValue: chartData.previousTotal, + metric: metric + ) + } + + private func refreshChartData() async { + let chartData = await generateChartData( + dataPoints: dataPoints, + dateRange: dateRange, + metric: metric, + 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 { + Menu { + Section { + ControlGroup { + ForEach(ChartType.allCases, id: \.self) { type in + Button { + chartType = type + } label: { + Label(type.localizedTitle, systemImage: type.systemImage) + } + } + } + } + } label: { + Image(systemName: "ellipsis") + .font(.body) + .foregroundColor(.secondary) + .frame(width: 56, 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) { + navigationButton(direction: .backward) + navigationButton(direction: .forward) + } + } + } + + @ViewBuilder + private func navigationButton(direction: Calendar.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, + calendar: Calendar, + granularity: DateRangeGranularity +) async -> ChartData { + let aggregator = StatsDataAggregator(calendar: 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: calendar + ) + + return ChartData( + metric: metric, + granularity: granularity, + currentTotal: currentPeriod.total, + currentData: currentPeriod.dataPoints, + previousTotal: previousPeriod.total, + previousData: previousPeriod.dataPoints, + mappedPreviousData: mappedPreviousData + ) +} + +// MARK: - Preview + +#Preview { + struct PreviewWrapper: View { + @State private var chartType: ChartType = .line + + var body: some View { + StandaloneChartCard( + dataPoints: generateMockDataPoints(days: 365), + metric: .views, + initialDateRange: Calendar.demo.makeDateRange(for: .last7Days), + chartType: $chartType + ) + } + } + + return PreviewWrapper() + .cardStyle() + .padding(8) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .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.demo + let today = Date() + + return (0.. 1 { + Menu { + itemTypePicker + } label: { + InlineValuePickerTitle(title: viewModel.selection.item.localizedTitle) + .padding(.top, 6) + .padding(.vertical, Constants.step0_5) // Increase tap area + } + .fixedSize() + } else { + Text(viewModel.selection.item.localizedTitle) + .padding(.top, 6) + .padding(.vertical, Constants.step0_5) + .font(.subheadline) + .fontWeight(.medium) + } + + Spacer() + + let metrics = getSupportedMetrics(for: viewModel.selection.item) + if metrics.count > 1 { + Menu { + makeMetricPicker(with: metrics) + } label: { + InlineValuePickerTitle(title: viewModel.selection.metric.localizedTitle) + .padding(.top, 6) + .padding(.vertical, Constants.step0_5) + } + .fixedSize() + } else { + Text(viewModel.selection.metric.localizedTitle) + .padding(.top, 6) + .padding(.vertical, Constants.step0_5) + .font(.subheadline) + .fontWeight(.medium) + } + } + } + + private func navigateToTopListScreen() { + let screen = TopListScreenView( + selection: viewModel.selection, + dateRange: viewModel.dateRange, + service: context.service, + context: context, + initialData: viewModel.data, + filter: viewModel.filter + ) + .environment(\.context, context) + .environment(\.router, router) + + router.navigate(to: screen, title: viewModel.selection.item.localizedTitle) + } + + private var itemTypePicker: some View { + 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) + } + } + } + } + .tint(Color.primary) + } + + private func makeMetricPicker(with metrics: [SiteMetric]) -> some View { + ForEach(metrics) { metric in + Button { + viewModel.selection.metric = metric + } label: { + Label(metric.localizedTitle, systemImage: metric.systemImage) + } + } + .tint(Color.primary) + } + + private func getSupportedMetrics(for item: TopListItemType) -> [SiteMetric] { + context.service.getSupportedMetrics(for: item) + } + + private var moreMenu: some View { + Menu { + moreMenuContent + } label: { + Image(systemName: "ellipsis") + .font(.system(size: 17)) + .foregroundColor(.secondary) + .frame(width: 56, height: 50) + } + .tint(Color.primary) + } + + @ViewBuilder + private var moreMenuContent: some View { + Section { + if let documentationURL = viewModel.selection.item.documentationURL { + Link(destination: documentationURL) { + Label(Strings.Buttons.learnMore, systemImage: "info.circle") + } + } + } + EditCardMenuContent(cardViewModel: viewModel) + } + + @ViewBuilder + private var listContentView: some View { + Group { + if viewModel.isFirstLoad { + topListItemsView(data: mockData) + .allowsHitTesting(false) + .redacted(reason: .placeholder) + .pulsating() + } else if let data = viewModel.data { + if data.items.isEmpty { + makeEmptyStateView(message: Strings.Chart.empty) + } else { + topListItemsView(data: data) + } + } else { + makeEmptyStateView(message: viewModel.loadingError?.localizedDescription ?? Strings.Errors.generic) + } + } + } + + private func topListItemsView(data: TopListData) -> some View { + VStack(spacing: 0) { + TopListItemsView( + data: data, + itemLimit: showMoreInline && isExpanded ? data.items.count : itemLimit, + dateRange: viewModel.dateRange, + reserveSpace: reserveSpace + ) + if showMoreInline && data.items.count > itemLimit { + showMoreInlineButton + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, Constants.step3) + } else if !showMoreInline { + showMoreButton + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, Constants.step3) + } + } + } + + private var showMoreButton: some View { + Button { + navigateToTopListScreen() + } label: { + HStack(spacing: 4) { + Text(Strings.Buttons.showAll) + .padding(.trailing, 4) + .font(.callout) + .foregroundColor(.primary) + Image(systemName: "chevron.forward") + .font(.caption.weight(.semibold)) + .foregroundColor(.secondary) + } + .font(.body) + } + .padding(.top, 16) + .tint(Color.secondary.opacity(0.8)) + .dynamicTypeSize(...DynamicTypeSize.xLarge) + } + + private var showMoreInlineButton: some View { + Button { + withAnimation(.spring) { + isExpanded.toggle() + } + } label: { + HStack(spacing: 4) { + Text(isExpanded ? Strings.Buttons.showLess : Strings.Buttons.showMore) + .padding(.trailing, 4) + .font(.callout) + .foregroundColor(.primary) + Image(systemName: isExpanded ? "chevron.up" : "chevron.down") + .font(.caption.weight(.semibold)) + .foregroundColor(.secondary) + } + .font(.body) + .frame(maxWidth: .infinity, alignment: .center) // Expand tap area + } + .padding(.top, 16) + .tint(Color.secondary.opacity(0.8)) + .dynamicTypeSize(...DynamicTypeSize.xLarge) + } + + private func makeEmptyStateView(message: String) -> some View { + topListItemsView(data: .init(item: viewModel.selection.item, metric: viewModel.selection.metric, items: [])) + .allowsHitTesting(false) + .redacted(reason: .placeholder) + .overlay { + SimpleErrorView(message: message) + .offset(y: -18) + } + } + + private var mockData: TopListData { + TopListData.mock( + for: viewModel.selection.item, + metric: viewModel.selection.metric, + itemCount: itemLimit + ) + } +} + +#Preview { + TopListCardPreview(item: .authors) +} + +private struct TopListCardPreview: View { + let item: TopListItemType + + @StateObject private var viewModel: TopListViewModel + + init(item: TopListItemType) { + self.item = item + self._viewModel = StateObject(wrappedValue: TopListViewModel( + configuration: TopListCardConfiguration( + item: item, + metric: item == .fileDownloads ? .downloads : .views + ), + dateRange: Calendar.demo.makeDateRange(for: .last28Days), + service: MockStatsService(), + tracker: MockStatsTracker.shared + )) + } + + var body: some View { + TopListCard(viewModel: viewModel) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Constants.Colors.background) + } +} diff --git a/Modules/Sources/JetpackStats/Cards/TopListCardConfiguration.swift b/Modules/Sources/JetpackStats/Cards/TopListCardConfiguration.swift new file mode 100644 index 000000000000..93df597188f4 --- /dev/null +++ b/Modules/Sources/JetpackStats/Cards/TopListCardConfiguration.swift @@ -0,0 +1,13 @@ +import Foundation + +struct TopListCardConfiguration: Codable { + let id: UUID + var item: TopListItemType + var metric: SiteMetric + + init(id: UUID = UUID(), item: TopListItemType, metric: SiteMetric) { + self.id = id + self.item = item + self.metric = metric + } +} diff --git a/Modules/Sources/JetpackStats/Cards/TopListViewModel.swift b/Modules/Sources/JetpackStats/Cards/TopListViewModel.swift new file mode 100644 index 000000000000..595c12a9067f --- /dev/null +++ b/Modules/Sources/JetpackStats/Cards/TopListViewModel.swift @@ -0,0 +1,265 @@ +import SwiftUI + +@MainActor +final class TopListViewModel: ObservableObject, TrafficCardViewModel { + var id: UUID { configuration.id } + let items: [TopListItemType] + let groupedItems: [[TopListItemType]] + + var title: String { + selection.item.getTitle(for: selection.metric) + } + + @Published private(set) var configuration: TopListCardConfiguration { + didSet { + configurationDelegate?.saveConfiguration(for: self) + updateSelection() + } + } + + @Published var selection: Selection { + didSet { + loadData() + } + } + @Published private(set) var data: TopListData? + @Published private(set) var isLoading = true + @Published private(set) var loadingError: Error? + @Published private(set) var isStale = false + @Published private(set) var cachedCountriesMapData: CountriesMapData? + + @Published var isEditing = false + + weak var configurationDelegate: CardConfigurationDelegate? + + let filter: Filter? + + private let service: any StatsServiceProtocol + let tracker: (any StatsTracker)? + private let fetchLimit: Int? + + private var loadingTask: Task? + private var loadRequestCount = 0 + private var staleTimer: Task? + + var dateRange: StatsDateRange { + didSet { loadData() } + } + + struct Selection: Equatable, Sendable { + var item: TopListItemType + var metric: SiteMetric + } + + enum Filter: Equatable { + case author(userId: String) + } + + var isFirstLoad: Bool { isLoading && data == nil } + + private var isFirstAppear = true + + init( + configuration: TopListCardConfiguration, + dateRange: StatsDateRange, + service: any StatsServiceProtocol, + tracker: (any StatsTracker)? = nil, + items: [TopListItemType]? = nil, + fetchLimit: Int? = 100, + filter: Filter? = nil, + initialData: TopListData? = nil + ) { + self.configuration = configuration + self.selection = Selection(item: configuration.item, metric: configuration.metric) + self.items = items ?? service.supportedItems + self.dateRange = dateRange + self.service = service + self.tracker = tracker + self.fetchLimit = fetchLimit + self.filter = filter + self.data = initialData + self.isLoading = initialData == nil + + self.groupedItems = { + let primary = service.supportedItems.filter { + !TopListItemType.secondaryItems.contains($0) + } + let secondary = service.supportedItems.filter { + TopListItemType.secondaryItems.contains($0) + } + return [primary, secondary] + }() + } + + func updateConfiguration(_ newConfiguration: TopListCardConfiguration) { + self.configuration = newConfiguration + } + + private func updateSelection() { + selection = Selection(item: configuration.item, metric: configuration.metric) + } + + func onAppear() { + guard isFirstAppear else { return } + isFirstAppear = false + + // Track card shown event + tracker?.send(.cardShown, properties: [ + "card_type": "top_list", + "configuration": "\(selection.item.analyticsName)_\(selection.metric.analyticsName)", + "item_type": selection.item.analyticsName, + "metric": selection.metric.analyticsName + ]) + + loadData() + } + + private func loadData() { + 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 data != nil { + staleTimer = Task { [weak self] in + try? await Task.sleep(for: .seconds(2)) + guard !Task.isCancelled else { return } + self?.isStale = true + } + } + + // Create a new loading task + loadingTask = Task { [selection, dateRange, 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 } + await self.actuallyLoadData(for: selection, dateRange: dateRange) + } + } + + private func actuallyLoadData(for selection: Selection, dateRange: StatsDateRange) async { + isLoading = true + loadingError = nil + + do { + try Task.checkCancellation() + + let data = try await getTopListData(for: selection, 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 + self.data = data + + // Update cached CountriesMapData if locations are selected + if selection.item == .locations { + updateCountriesMapDataCache(from: data) + } else { + cachedCountriesMapData = nil + } + } catch is CancellationError { + return + } catch { + loadingError = error + data = nil + tracker?.trackError(error, screen: "top_list_card") + } + + loadRequestCount = 0 + isLoading = false + } + + private func getTopListData(for selection: Selection, dateRange: StatsDateRange) async throws -> TopListData { + let granularity = dateRange.dateInterval.preferredGranularity + + // When filter is set for author, we need to fetch authors data + 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( + fetchItem, + metric: selection.metric, + interval: dateRange.dateInterval, + granularity: granularity, + limit: fetchLimit + ) + + // Fetch previous data only for items that support it + async let previousTask: TopListResponse? = { + guard selection.item != .archive else { return nil } + return try await service.getTopListData( + fetchItem, + metric: selection.metric, + interval: dateRange.effectiveComparisonInterval, + granularity: granularity, + limit: fetchLimit + ) + }() + + 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 TopListItemProtocol] = [:] + for item in previousItems { + previousItemsDict[item.id] = item + } + + // Calculate max value from filtered items based on selected metric + let metric = selection.metric + + return TopListData( + item: selection.item, + metric: metric, + items: currentItems, + previousItems: previousItemsDict + ) + } + + private func filteredItems(_ items: [any TopListItemProtocol]) -> [any TopListItemProtocol] { + guard let filter else { + return items + } + switch filter { + case .author(let userId): + let authors = items.lazy.compactMap { $0 as? TopListItem.Author } + if let author = authors.first(where: { $0.userId == userId }), + let posts = author.posts { + return posts + } + return [] + } + } + + private func updateCountriesMapDataCache(from data: TopListData) { + let locations = data.items.compactMap { $0 as? TopListItem.Location } + let previousLocations = data.previousItems.compactMapValues { $0 as? TopListItem.Location } + + cachedCountriesMapData = CountriesMapData( + metric: selection.metric, + locations: locations, + previousLocations: previousLocations + ) + } + } diff --git a/Modules/Sources/JetpackStats/Cards/TrafficCardConfiguration.swift b/Modules/Sources/JetpackStats/Cards/TrafficCardConfiguration.swift new file mode 100644 index 000000000000..6d3bde9e51f0 --- /dev/null +++ b/Modules/Sources/JetpackStats/Cards/TrafficCardConfiguration.swift @@ -0,0 +1,19 @@ +import Foundation + +struct TrafficCardConfiguration: Codable { + var cards: [Card] + + enum Card: Codable { + case chart(ChartCardConfiguration) + case topList(TopListCardConfiguration) + + var id: UUID { + switch self { + case .chart(let config): + return config.id + case .topList(let config): + return config.id + } + } + } +} diff --git a/Modules/Sources/JetpackStats/Charts/BarChartView.swift b/Modules/Sources/JetpackStats/Charts/BarChartView.swift new file mode 100644 index 000000000000..7089d8fd96f7 --- /dev/null +++ b/Modules/Sources/JetpackStats/Charts/BarChartView.swift @@ -0,0 +1,253 @@ +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) + } + + private var currentAverage: Double { + guard !data.currentData.isEmpty else { return 0 } + return Double(data.currentTotal) / Double(data.currentData.count) + } + + var body: some View { + Chart { + previousPeriodBars + currentPeriodBars + averageLine + significantPointAnnotations + selectionIndicatorMarks + } + .chartXAxis { xAxis } + .chartYAxis { yAxis } + .chartYScale(domain: yAxisDomain) + .chartLegend(.hidden) + .environment(\.timeZone, context.timeZone) + .modifier(ChartSelectionModifier(selection: $selectedDate)) + .animation(.spring, value: ObjectIdentifier(data)) + .onChange(of: selectedDate) { + selectedDataPoints = SelectedDataPoints.compute(for: $0, data: data) + } + .dynamicTypeSize(...DynamicTypeSize.xxxLarge) + .accessibilityElement() + .accessibilityLabel(Strings.Accessibility.chartContainer) + .accessibilityHint(Strings.Accessibility.viewChartData) + } + + // 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: .automatic + ) + .foregroundStyle( + LinearGradient( + colors: [ + data.metric.primaryColor, + data.metric.primaryColor.opacity(0.5) + ], + startPoint: .top, + endPoint: .bottom + ) + ) + .cornerRadius(6) + .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: .automatic, + stacking: .unstacked + ) + .foregroundStyle(Color.secondary) + .cornerRadius(6) + .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 averageLine: some ChartContent { + if currentAverage > 0 { + RuleMark(y: .value("Average", currentAverage)) + .foregroundStyle(Color.secondary.opacity(0.33)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [6, 6])) + .annotation(position: .trailing, alignment: .trailing) { + ChartAverageAnnotation(value: Int(currentAverage), formatter: valueFormatter) + } + } + } + + @ChartContentBuilder + private var significantPointAnnotations: some ChartContent { + if let maxPoint = data.significantPoints.currentMax, data.currentData.count > 0 { + PointMark( + x: .value("Date", maxPoint.date, unit: data.granularity.component), + y: .value("Value", maxPoint.value) + ) + .opacity(0) + .annotation(position: .top, spacing: 8) { + SignificantPointAnnotation( + value: maxPoint.value, + metric: data.metric + ) + // Important for drag selection to work correctly. + .opacity(selectedDate == nil ? 1 : 0) + } + } + } + + @ChartContentBuilder + private var selectionIndicatorMarks: some ChartContent { + if #available(iOS 17.0, *), + let selectedDate, + let selectedDataPoints { + + if let currentPoint = selectedDataPoints.current { + RuleMark(x: .value("Selected", currentPoint.date)) + .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 + } + } else if let previousPoint = selectedDataPoints.previous { + RuleMark(x: .value("Selected", previousPoint.date)) + .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) + } + } + } + } + } + + private var yAxisDomain: ClosedRange { + // If all values are zero, show a reasonable range + if data.maxValue == 0 { + return 0...100 + } + guard data.maxValue > 0 else { + return data.maxValue...0 // Just in case; should never happend + } + // Add some padding above the max value + let padding = max(Int(Double(data.maxValue) * 0.66), 1) + return 0...(data.maxValue + padding) + } + + // MARK: - Helper Views + + @ViewBuilder + private var tooltipView: some View { + if let selectedPoints = selectedDataPoints { + ChartValueTooltipView( + selectedPoints: selectedPoints, + 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/Helpers/ChartAverageAnnotation.swift b/Modules/Sources/JetpackStats/Charts/Helpers/ChartAverageAnnotation.swift new file mode 100644 index 000000000000..10503e4f70a4 --- /dev/null +++ b/Modules/Sources/JetpackStats/Charts/Helpers/ChartAverageAnnotation.swift @@ -0,0 +1,21 @@ +import SwiftUI + +struct ChartAverageAnnotation: View { + let value: Int + let formatter: StatsValueFormatter + + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + Text(formatter.format(value: value, context: .compact)) + .font(.caption2.weight(.medium)).tracking(-0.1) + .foregroundStyle(Color.secondary) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background( + colorScheme == .light ? Constants.Colors.background : Color(.opaqueSeparator).opacity(0.8) + ) + .clipShape(.capsule) + .padding(.leading, -5) + } +} diff --git a/Modules/Sources/JetpackStats/Charts/Helpers/ChartData.swift b/Modules/Sources/JetpackStats/Charts/Helpers/ChartData.swift new file mode 100644 index 000000000000..8e53703cda1d --- /dev/null +++ b/Modules/Sources/JetpackStats/Charts/Helpers/ChartData.swift @@ -0,0 +1,152 @@ +import SwiftUI + +final class ChartData: Sendable { + let metric: SiteMetric + let granularity: DateRangeGranularity + let currentTotal: Int + let currentData: [DataPoint] + let previousTotal: Int + let previousData: [DataPoint] + let mappedPreviousData: [DataPoint] + let maxValue: Int + let significantPoints: SignificantPoints + let isEmptyOrZero: Bool + + var isEmpty: Bool { + currentData.isEmpty && previousData.isEmpty + } + + struct SignificantPoints: Sendable { + let currentMax: DataPoint? + let currentMin: DataPoint? + let previousMax: DataPoint? + let previousMin: 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 + + var maxValue = 0 // Faster without creating intermediate arrays + var currentMaxPoint: DataPoint? + var currentMinPoint: DataPoint? + + for point in currentData { + if point.value > maxValue { + maxValue = point.value + currentMaxPoint = point + } + if point.value > 0 && (currentMinPoint == nil || point.value < currentMinPoint!.value) { + currentMinPoint = point + } + } + + var previousMaxPoint: DataPoint? + var previousMinPoint: DataPoint? + + for point in mappedPreviousData { + maxValue = max(maxValue, point.value) + if previousMaxPoint == nil || point.value > previousMaxPoint!.value { + previousMaxPoint = point + } + if point.value > 0 && (previousMinPoint == nil || point.value < previousMinPoint!.value) { + previousMinPoint = point + } + } + + self.maxValue = maxValue + self.significantPoints = SignificantPoints( + currentMax: currentMaxPoint, + currentMin: currentMinPoint, + previousMax: previousMaxPoint, + previousMin: previousMinPoint + ) + + // Check if all data points are zero + self.isEmptyOrZero = currentData.allSatisfy { $0.value == 0 } && previousData.allSatisfy { $0.value == 0 } + } +} + +// 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, + currentTotal: DataPoint.getTotalValue(for: dataPoints, metric: metric) ?? 0, + currentData: dataPoints, + previousTotal: DataPoint.getTotalValue(for: previousData, metric: metric) ?? 0, + 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 .posts: 10...50 + case .timeOnSite: 120...300 + case .bounceRate: 40...80 + case .downloads: 100...250 + } + } + + /// Clamps the given date to be within the range of current data points + func clampDateToDataRange(_ date: Date) -> Date { + guard let firstDate = currentData.first?.date, + let lastDate = currentData.last?.date else { + return date + } + + if date < firstDate { + return firstDate + } else if date > lastDate { + return lastDate + } + return date + } +} diff --git a/Modules/Sources/JetpackStats/Charts/Helpers/SignificantPointAnnotation.swift b/Modules/Sources/JetpackStats/Charts/Helpers/SignificantPointAnnotation.swift new file mode 100644 index 000000000000..ec9fd9dbf397 --- /dev/null +++ b/Modules/Sources/JetpackStats/Charts/Helpers/SignificantPointAnnotation.swift @@ -0,0 +1,42 @@ +import SwiftUI + +struct SignificantPointAnnotation: View { + let value: Int + let metric: SiteMetric + let valueFormatter: StatsValueFormatter + + @Environment(\.colorScheme) private var colorScheme + + init(value: Int, metric: SiteMetric) { + self.value = value + self.metric = metric + self.valueFormatter = StatsValueFormatter(metric: metric) + } + + var body: some View { + Text(valueFormatter.format(value: value, context: .compact)) + .fixedSize() + .font(.system(.caption, design: .rounded, weight: .semibold)) + .foregroundColor(metric.primaryColor) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background { + ZStack { + Capsule() + .fill(Color(.systemBackground).opacity(0.75)) + Capsule() + .fill(metric.primaryColor.opacity(colorScheme == .light ? 0.1 : 0.25)) + } + } + } +} + +#Preview { + VStack(spacing: 20) { + SignificantPointAnnotation(value: 50000, metric: .views) + SignificantPointAnnotation(value: 1234, metric: .visitors) + SignificantPointAnnotation(value: 89, metric: .likes) + } + .padding() + .background(Color(.systemGroupedBackground)) +} diff --git a/Modules/Sources/JetpackStats/Charts/LineChartView.swift b/Modules/Sources/JetpackStats/Charts/LineChartView.swift new file mode 100644 index 000000000000..d4586262d9a3 --- /dev/null +++ b/Modules/Sources/JetpackStats/Charts/LineChartView.swift @@ -0,0 +1,268 @@ +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 currentAverage: Double { + guard !data.currentData.isEmpty else { return 0 } + return Double(data.currentTotal) / Double(data.currentData.count) + } + + var body: some View { + Chart { + currentPeriodMarks + previousPeriodMarks + averageLine + significantPointAnnotations + selectionIndicatorMarks + } + .chartXAxis { xAxis } + .chartYAxis { yAxis } + .chartYScale(domain: yAxisDomain) + .chartLegend(.hidden) + .environment(\.timeZone, context.timeZone) + .modifier(ChartSelectionModifier(selection: $selectedDate)) + .animation(.spring, value: ObjectIdentifier(data)) + .onChange(of: selectedDate) { + selectedDataPoints = SelectedDataPoints.compute(for: $0, data: data) + } + .dynamicTypeSize(...DynamicTypeSize.xxxLarge) + .accessibilityElement() + .accessibilityLabel(Strings.Accessibility.chartContainer) + .accessibilityHint(Strings.Accessibility.viewChartData) + } + + // 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( + LinearGradient( + colors: [ + data.metric.primaryColor.opacity(colorScheme == .light ? 0.15 : 0.25), + data.metric.primaryColor.opacity(0.0) + ], + startPoint: .top, + endPoint: .bottom + ) + ) + .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 averageLine: some ChartContent { + if currentAverage > 0 { + RuleMark(y: .value("Average", currentAverage)) + .foregroundStyle(Color.secondary.opacity(0.33)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [6, 6])) + .annotation(position: .trailing, alignment: .trailing) { + ChartAverageAnnotation(value: Int(currentAverage), formatter: valueFormatter) + } + } + } + + @ChartContentBuilder + private var significantPointAnnotations: some ChartContent { + if let maxPoint = data.significantPoints.currentMax, data.currentData.count > 0 { + PointMark( + x: .value("Date", maxPoint.date), + y: .value("Value", maxPoint.value) + ) + .foregroundStyle(data.metric.primaryColor) + .symbolSize(60) + .annotation(position: .top, spacing: 4) { + SignificantPointAnnotation(value: maxPoint.value, metric: data.metric) + .opacity(selectedDate == nil ? 1 : 0) + } + .opacity(selectedDate == nil ? 1 : 0) + } + } + + @ChartContentBuilder + private var selectionIndicatorMarks: some ChartContent { + if #available(iOS 17.0, *), + let selectedDate, let selectedPoints = selectedDataPoints { + + if let currentPoint = selectedPoints.current { + RuleMark(x: .value("Selected", currentPoint.date)) + .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 + } + + PointMark( + x: .value("Date", currentPoint.date), + y: .value("Value", currentPoint.value) + ) + .foregroundStyle(data.metric.primaryColor) + .symbolSize(80) + } else if let previousPoint = selectedPoints.previous { + RuleMark(x: .value("Selected", previousPoint.date)) + .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 + } + + 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)).tracking(-0.1) + .foregroundColor(.secondary) + } + } + } + } + + private var yAxisDomain: ClosedRange { + // If all values are zero, show a reasonable range + if data.maxValue == 0 { + return 0...100 + } + guard data.maxValue > 0 else { + return data.maxValue...0 // Just in case; should never happend + } + // Add some padding above the max value + let padding = max(Int(Double(data.maxValue) * 0.66), 1) + return 0...(data.maxValue + padding) + } + + // MARK: - Helper Views + + @ViewBuilder + private var tooltipView: some View { + if let selectedPoints = selectedDataPoints { + ChartValueTooltipView( + selectedPoints: selectedPoints, + 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..79c84092b2f8 --- /dev/null +++ b/Modules/Sources/JetpackStats/Constants.swift @@ -0,0 +1,107 @@ +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 background = Color(UIColor( + light: UIColor.secondarySystemBackground, + dark: UIColor.systemBackground + )) + + static let secondaryBackground = Color(UIColor( + light: UIColor.systemBackground, + dark: UIColor.secondarySystemBackground + )) + + 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 yellow = Color(palette: CSColor.Yellow.self) + static let pink = Color(palette: CSColor.Pink.self) + static let celadon = Color(palette: CSColor.Celadon.self) + + static let uiColorBlue = UIColor(palette: CSColor.Blue.self) + + static let jetpack = Color(palette: CSColor.JetpackGreen.self) + + static let shadowColor = Color(UIColor( + light: UIColor.black.withAlphaComponent(0.1), + dark: UIColor.white.withAlphaComponent(0.1) + )) + } + + static let step0_5: CGFloat = 9 + static let step1: CGFloat = 12 + static let step2: CGFloat = 18 + static let step3: CGFloat = 24 + static let step4: CGFloat = 32 + + /// For raw lists like TopListScreenView etc. + static let maxHortizontalWidthPlainLists: CGFloat = 660 + static let maxHortizontalWidth: CGFloat = 760 + + static let cardPadding = EdgeInsets(top: step2, leading: step3, bottom: step2, trailing: step3) + + /// Horizontal insets for screens containing cards + static let cardHorizontalInsetRegular: CGFloat = step3 + static let cardHorizontalInsetCompact: CGFloat = step1 + + /// Returns the appropriate horizontal card inset for the given size class + static func cardHorizontalInset(for sizeClass: UserInterfaceSizeClass?) -> CGFloat { + sizeClass == .regular ? cardHorizontalInsetRegular : cardHorizontalInsetCompact + } + + static func heatmapColor(baseColor: Color, intensity: Double, colorScheme: ColorScheme) -> Color { + if intensity == 0 { + return Color(UIColor( + light: UIColor.secondarySystemBackground, + dark: UIColor.tertiarySystemBackground + )) + } + + // Use graduated opacity based on intensity + if intensity <= 0.25 { + return baseColor.opacity(0.07) + } else if intensity <= 0.5 { + return baseColor.opacity(colorScheme == .light ? 0.14 : 0.2) + } else if intensity <= 0.75 { + return baseColor.opacity(colorScheme == .light ? 0.25 : 0.32) + } else { + return baseColor.opacity(colorScheme == .light ? 0.38 : 0.60) + } + } +} + +private extension Color { + init(palette: T.Type) { + self.init(uiColor: UIColor(palette: palette)) + } +} + +private extension UIColor { + convenience init(palette: T.Type) { + self.init(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..eb71850ae320 --- /dev/null +++ b/Modules/Sources/JetpackStats/Extensions/Calendar+Presets.swift @@ -0,0 +1,140 @@ +import Foundation + +/// Represents predefined date range options for stats filtering. +/// Each preset defines a specific time period relative to the current date. +enum DateIntervalPreset: String, 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 3 complete years, including the current year + case last3Years + /// 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 .last3Years: Strings.Calendar.last3Years + case .last10Years: Strings.Calendar.last10Years + } + } + + var prefersDateIntervalFormatting: Bool { + switch self { + case .today, .last7Days, .last28Days, .last30Days, .last90Days, .last6Months, .last12Months, .thisWeek: + return false + case .thisMonth, .thisYear, .thisQuarter, .last3Years, .last10Years: + return true + } + } + + /// 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, .last3Years, .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 .last3Years: makeDateInterval(offset: -3, 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/Color+Extensions.swift b/Modules/Sources/JetpackStats/Extensions/Color+Extensions.swift new file mode 100644 index 000000000000..8678c5f62d00 --- /dev/null +++ b/Modules/Sources/JetpackStats/Extensions/Color+Extensions.swift @@ -0,0 +1,39 @@ +import UIKit + +extension UIColor { + /// Converts a UIColor to its hex string representation + func toHex() -> String { + var red: CGFloat = 0 + 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), + 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: max(0, r), green: max(0, g), blue: max(0, b), alpha: max(0, a)) + } + + /// Lightens the color by mixing it with white + func lightened(by percentage: Double) -> UIColor { + UIColor.interpolate(from: self, to: .white, fraction: percentage) + } +} 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 000000000000..9225f29af403 Binary files /dev/null and b/Modules/Sources/JetpackStats/Resources/Mocks/Avatars/author1.jpg differ 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 000000000000..1b793bf95db5 Binary files /dev/null and b/Modules/Sources/JetpackStats/Resources/Mocks/Avatars/author2.jpg differ 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 000000000000..3fd382ed7bbd Binary files /dev/null and b/Modules/Sources/JetpackStats/Resources/Mocks/Avatars/author3.jpg differ diff --git a/Modules/Sources/JetpackStats/Resources/Mocks/Avatars/author4.jpg b/Modules/Sources/JetpackStats/Resources/Mocks/Avatars/author4.jpg new file mode 100644 index 000000000000..89eb66540fdd Binary files /dev/null and b/Modules/Sources/JetpackStats/Resources/Mocks/Avatars/author4.jpg differ diff --git a/Modules/Sources/JetpackStats/Resources/Mocks/Avatars/author5.jpg b/Modules/Sources/JetpackStats/Resources/Mocks/Avatars/author5.jpg new file mode 100644 index 000000000000..2bec353d4503 Binary files /dev/null and b/Modules/Sources/JetpackStats/Resources/Mocks/Avatars/author5.jpg differ 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 000000000000..befcc31556b5 Binary files /dev/null and b/Modules/Sources/JetpackStats/Resources/Mocks/Avatars/author6.jpg differ 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..ef5a5d7aa258 --- /dev/null +++ b/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-archive.json @@ -0,0 +1,344 @@ +[ + { + "sectionName": "homepage", + "items": [ + { + "href": "https://example.com/", + "value": "/", + "metrics": { + "views": 8500 + } + } + ], + "metrics": { + "views": 8500 + } + }, + { + "sectionName": "categories", + "items": [ + { + "href": "https://example.com/category/tech-news/", + "value": "tech-news", + "metrics": { + "views": 4200 + } + }, + { + "href": "https://example.com/category/reviews/", + "value": "reviews", + "metrics": { + "views": 3800 + } + }, + { + "href": "https://example.com/category/transportation/", + "value": "transportation", + "metrics": { + "views": 2800 + } + }, + { + "href": "https://example.com/category/ai/", + "value": "artificial-intelligence", + "metrics": { + "views": 2600 + } + }, + { + "href": "https://example.com/category/gaming/", + "value": "gaming", + "metrics": { + "views": 2200 + } + }, + { + "href": "https://example.com/category/apple/", + "value": "apple", + "metrics": { + "views": 2000 + } + }, + { + "href": "https://example.com/category/google/", + "value": "google", + "metrics": { + "views": 1600 + } + }, + { + "href": "https://example.com/category/microsoft/", + "value": "microsoft", + "metrics": { + "views": 1400 + } + }, + { + "href": "https://example.com/category/entertainment/", + "value": "entertainment", + "metrics": { + "views": 1200 + } + }, + { + "href": "https://example.com/category/science/", + "value": "science", + "metrics": { + "views": 800 + } + } + ], + "metrics": { + "views": 22600 + } + }, + { + "sectionName": "tags", + "items": [ + { + "href": "https://example.com/tag/iphone/", + "value": "iphone", + "metrics": { + "views": 1800 + } + }, + { + "href": "https://example.com/tag/android/", + "value": "android", + "metrics": { + "views": 1600 + } + }, + { + "href": "https://example.com/tag/tesla/", + "value": "tesla", + "metrics": { + "views": 1400 + } + }, + { + "href": "https://example.com/tag/cybertruck/", + "value": "cybertruck", + "metrics": { + "views": 1200 + } + }, + { + "href": "https://example.com/tag/vr/", + "value": "virtual-reality", + "metrics": { + "views": 1000 + } + }, + { + "href": "https://example.com/tag/laptops/", + "value": "laptops", + "metrics": { + "views": 900 + } + }, + { + "href": "https://example.com/tag/ai/", + "value": "ai", + "metrics": { + "views": 850 + } + }, + { + "href": "https://example.com/tag/chatgpt/", + "value": "chatgpt", + "metrics": { + "views": 800 + } + }, + { + "href": "https://example.com/tag/streaming/", + "value": "streaming", + "metrics": { + "views": 600 + } + }, + { + "href": "https://example.com/tag/privacy/", + "value": "privacy", + "metrics": { + "views": 500 + } + } + ], + "metrics": { + "views": 10650 + } + }, + { + "sectionName": "archives", + "items": [ + { + "href": "https://example.com/2024/11/", + "value": "November 2024", + "metrics": { + "views": 3200 + } + }, + { + "href": "https://example.com/2024/10/", + "value": "October 2024", + "metrics": { + "views": 4800 + } + }, + { + "href": "https://example.com/2024/09/", + "value": "September 2024", + "metrics": { + "views": 3600 + } + }, + { + "href": "https://example.com/2024/08/", + "value": "August 2024", + "metrics": { + "views": 2800 + } + }, + { + "href": "https://example.com/2024/07/", + "value": "July 2024", + "metrics": { + "views": 2400 + } + }, + { + "href": "https://example.com/2024/06/", + "value": "June 2024", + "metrics": { + "views": 2000 + } + } + ], + "metrics": { + "views": 18800 + } + }, + { + "sectionName": "author", + "items": [ + { + "href": "https://example.com/author/alex-johnson/", + "value": "alex-johnson", + "metrics": { + "views": 2800 + } + }, + { + "href": "https://example.com/author/chloe-zhang/", + "value": "chloe-zhang", + "metrics": { + "views": 1800 + } + }, + { + "href": "https://example.com/author/jordan-davis/", + "value": "jordan-davis", + "metrics": { + "views": 1600 + } + }, + { + "href": "https://example.com/author/sofia-rodriguez/", + "value": "sofia-rodriguez", + "metrics": { + "views": 1400 + } + }, + { + "href": "https://example.com/author/morgan-smith/", + "value": "morgan-smith", + "metrics": { + "views": 1200 + } + }, + { + "href": "https://example.com/author/emma-thompson/", + "value": "emma-thompson", + "metrics": { + "views": 1000 + } + }, + { + "href": "https://example.com/author/riley-martinez/", + "value": "riley-martinez", + "metrics": { + "views": 900 + } + }, + { + "href": "https://example.com/author/jamie-lee/", + "value": "jamie-lee", + "metrics": { + "views": 800 + } + }, + { + "href": "https://example.com/author/avery-taylor/", + "value": "avery-taylor", + "metrics": { + "views": 700 + } + }, + { + "href": "https://example.com/author/quinn-anderson/", + "value": "quinn-anderson", + "metrics": { + "views": 600 + } + } + ], + "metrics": { + "views": 12800 + } + }, + { + "sectionName": "other", + "items": [ + { + "href": "https://example.com/search/", + "value": "/search/", + "metrics": { + "views": 1500 + } + }, + { + "href": "https://example.com/rss/", + "value": "/rss/", + "metrics": { + "views": 800 + } + }, + { + "href": "https://example.com/sitemap.xml", + "value": "/sitemap.xml", + "metrics": { + "views": 400 + } + }, + { + "href": "https://example.com/wp-admin/", + "value": "/wp-admin/", + "metrics": { + "views": 200 + } + }, + { + "href": "https://example.com/robots.txt", + "value": "/robots.txt", + "metrics": { + "views": 100 + } + } + ], + "metrics": { + "views": 3000 + } + } +] \ No newline at end of file diff --git a/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-authors.json b/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-authors.json new file mode 100644 index 000000000000..02caa1a087ee --- /dev/null +++ b/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-authors.json @@ -0,0 +1,394 @@ +[ + { + "name": "Alex Johnson", + "userId": "1", + "role": "Editor-in-Chief", + "metrics": { + "views": 12000, + "comments": 580, + "likes": 2100, + "visitors": 8400, + "bounceRate": 25, + "timeOnSite": 320 + }, + "posts": [ + { + "title": "Best printer 2025: just buy a Brother laser printer, the winner is clear, middle finger in the air", + "postID": "1", + "postURL": "https://example.com/best-printer-2025", + "date": "2024-11-20T00:00:00Z", + "type": "post", + "author": "Alex Johnson", + "metrics": { + "views": 5800 + } + }, + { + "title": "I Replaced Every Component in My Framework Laptop Until It Became a Desktop", + "postID": "2", + "postURL": "https://example.com/framework-laptop-desktop", + "date": "2024-11-18T00:00:00Z", + "type": "post", + "author": "Alex Johnson", + "metrics": { + "views": 2500 + } + }, + { + "title": "The M3 MacBook Air Is Faster Than My Personality", + "postID": "20", + "postURL": "https://example.com/m3-macbook-air-review", + "date": "2024-10-02T00:00:00Z", + "type": "post", + "author": "Alex Johnson", + "metrics": { + "views": 3800 + } + } + ] + }, + { + "name": "Chloe Zhang", + "userId": "2", + "role": "Executive Editor", + "metrics": { + "views": 7800, + "comments": 380, + "likes": 1420, + "visitors": 5460, + "bounceRate": 32, + "timeOnSite": 270 + }, + "posts": [ + { + "title": "The Cybertruck's Frunk Won't Stop Eating Fingers", + "postID": "3", + "postURL": "https://example.com/cybertruck-frunk-safety", + "date": "2024-11-15T00:00:00Z", + "type": "post", + "author": "Chloe Zhang", + "metrics": { + "views": 2000 + } + }, + { + "title": "The $2000 Dyson Hair Dryer: A Scientific Analysis of My Poor Life Choices", + "postID": "16", + "postURL": "https://example.com/dyson-hair-dryer-review", + "date": "2024-10-12T00:00:00Z", + "type": "post", + "author": "Chloe Zhang", + "metrics": { + "views": 1800 + } + } + ] + }, + { + "name": "Jordan Davis", + "userId": "3", + "role": "Senior Writer", + "metrics": { + "views": 9200, + "comments": 450, + "likes": 1680, + "visitors": 6440, + "bounceRate": 30, + "timeOnSite": 310 + }, + "posts": [ + { + "title": "Matter Smart Home Protocol Still Doesn't Matter: A Year Later", + "postID": "4", + "postURL": "https://example.com/matter-smart-home-fail", + "date": "2024-11-12T00:00:00Z", + "type": "post", + "author": "Jordan Davis", + "metrics": { + "views": 1800 + } + }, + { + "title": "Netflix Password Sharing Crackdown: A Love Story", + "postID": "21", + "postURL": "https://example.com/netflix-password-sharing", + "date": "2024-09-30T00:00:00Z", + "type": "post", + "author": "Jordan Davis", + "metrics": { + "views": 5200 + } + } + ] + }, + { + "name": "Sofia Rodriguez", + "userId": "4", + "role": "Editor-at-Large", + "metrics": { + "views": 5650, + "comments": 275, + "likes": 1030, + "visitors": 3955, + "bounceRate": 34, + "timeOnSite": 285 + }, + "posts": [ + { + "title": "Trackpad Alignment on the New MacBook Pro: A 10,000 Word Investigation", + "postID": "5", + "postURL": "https://example.com/macbook-trackpad-investigation", + "date": "2024-11-10T00:00:00Z", + "type": "post", + "author": "Sofia Rodriguez", + "metrics": { + "views": 1600 + } + }, + { + "title": "I Shattered the Apple Vision Pro's Front Glass and It Only Cost Me $799 to Fix", + "postID": "11", + "postURL": "https://example.com/vision-pro-repair", + "date": "2024-10-25T00:00:00Z", + "type": "post", + "author": "Sofia Rodriguez", + "metrics": { + "views": 850 + } + }, + { + "title": "The Rabbit R1 Is Just an Android App in a Tiny Orange Box", + "postID": "22", + "postURL": "https://example.com/rabbit-r1-android", + "date": "2024-09-28T00:00:00Z", + "type": "post", + "author": "Sofia Rodriguez", + "metrics": { + "views": 2300 + } + } + ] + }, + { + "name": "Morgan Smith", + "userId": "5", + "role": "Senior Editor", + "metrics": { + "views": 8800, + "comments": 420, + "likes": 1560, + "visitors": 6160, + "bounceRate": 28, + "timeOnSite": 295 + }, + "posts": [ + { + "title": "Nothing Phone (2a): Now With 50% More Nothing", + "postID": "6", + "postURL": "https://example.com/nothing-phone-2a", + "date": "2024-11-08T00:00:00Z", + "type": "post", + "author": "Morgan Smith", + "metrics": { + "views": 1400 + } + }, + { + "title": "Twitter's New Logo Is Just the Letter X and Everyone Is Very Normal About It", + "postID": "17", + "postURL": "https://example.com/twitter-x-logo", + "date": "2024-10-10T00:00:00Z", + "type": "post", + "author": "Morgan Smith", + "metrics": { + "views": 6200 + } + } + ] + }, + { + "name": "Emma Thompson", + "userId": "6", + "role": "Managing Editor", + "metrics": { + "views": 6900, + "comments": 335, + "likes": 1255, + "visitors": 4830, + "bounceRate": 31, + "timeOnSite": 300 + }, + "posts": [ + { + "title": "Samsung's Moon Photography Is Real* (*Terms and Conditions Apply)", + "postID": "7", + "postURL": "https://example.com/samsung-moon-photography", + "date": "2024-11-05T00:00:00Z", + "type": "post", + "author": "Emma Thompson", + "metrics": { + "views": 1200 + } + }, + { + "title": "ChatGPT Convinced Me It Was Sentient (It Was Lying)", + "postID": "15", + "postURL": "https://example.com/chatgpt-sentient", + "date": "2024-10-15T00:00:00Z", + "type": "post", + "author": "Emma Thompson", + "metrics": { + "views": 4500 + } + } + ] + }, + { + "name": "Riley Martinez", + "userId": "7", + "role": "Senior Reporter", + "metrics": { + "views": 4600, + "comments": 225, + "likes": 840, + "visitors": 3220, + "bounceRate": 37, + "timeOnSite": 260 + }, + "posts": [ + { + "title": "The Steam Deck OLED Screen Is So Good I Licked It", + "postID": "8", + "postURL": "https://example.com/steam-deck-oled", + "date": "2024-11-03T00:00:00Z", + "type": "post", + "author": "Riley Martinez", + "metrics": { + "views": 1100 + } + }, + { + "title": "Meta's VR Legs Update: They're Still Not Real", + "postID": "9", + "postURL": "https://example.com/meta-vr-legs", + "date": "2024-10-30T00:00:00Z", + "type": "post", + "author": "Riley Martinez", + "metrics": { + "views": 1000 + } + }, + { + "title": "Sony's New $200 Controller Has a Screen Nobody Asked For", + "postID": "19", + "postURL": "https://example.com/sony-controller-screen", + "date": "2024-10-05T00:00:00Z", + "type": "post", + "author": "Riley Martinez", + "metrics": { + "views": 1500 + } + } + ] + }, + { + "name": "Jamie Lee", + "userId": "8", + "role": "News Writer", + "metrics": { + "views": 3900, + "comments": 190, + "likes": 710, + "visitors": 2730, + "bounceRate": 40, + "timeOnSite": 245 + }, + "posts": [ + { + "title": "Microsoft's New AI Can Predict When You'll Rage Quit Excel", + "postID": "10", + "postURL": "https://example.com/microsoft-ai-excel", + "date": "2024-10-28T00:00:00Z", + "type": "post", + "author": "Jamie Lee", + "metrics": { + "views": 900 + } + }, + { + "title": "This Smart Toaster Has DRM and I'm Not Even Surprised Anymore", + "postID": "14", + "postURL": "https://example.com/smart-toaster-drm", + "date": "2024-10-18T00:00:00Z", + "type": "post", + "author": "Jamie Lee", + "metrics": { + "views": 2100 + } + } + ] + }, + { + "name": "Avery Taylor", + "userId": "9", + "role": "News Writer", + "metrics": { + "views": 4350, + "comments": 215, + "likes": 790, + "visitors": 3045, + "bounceRate": 42, + "timeOnSite": 235 + }, + "posts": [ + { + "title": "Google's Pixel 8 Pro Has Too Many Cameras (I Counted)", + "postID": "12", + "postURL": "https://example.com/pixel-8-pro-cameras", + "date": "2024-10-22T00:00:00Z", + "type": "post", + "author": "Avery Taylor", + "metrics": { + "views": 750 + } + }, + { + "title": "I Tried to Return a Smart TV That Shows Ads. Best Buy Laughed at Me", + "postID": "18", + "postURL": "https://example.com/smart-tv-ads-return", + "date": "2024-10-08T00:00:00Z", + "type": "post", + "author": "Avery Taylor", + "metrics": { + "views": 2800 + } + } + ] + }, + { + "name": "Quinn Anderson", + "userId": "10", + "role": "Weekend Editor", + "metrics": { + "views": 3420, + "comments": 170, + "likes": 625, + "visitors": 2394, + "bounceRate": 36, + "timeOnSite": 270 + }, + "posts": [ + { + "title": "Apple Finally Invented USB-C (They're Calling It Revolutionary)", + "postID": "13", + "postURL": "https://example.com/apple-usb-c", + "date": "2024-10-20T00:00:00Z", + "type": "post", + "author": "Quinn Anderson", + "metrics": { + "views": 3200 + } + } + ] + } +] \ 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..3a452dcfd62d --- /dev/null +++ b/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-external-links.json @@ -0,0 +1,307 @@ +[ + { + "url": "https://apple.com", + "title": "Apple", + "children": [ + { + "url": "https://apple.com/iphone-15-pro", + "title": "iPhone 15 Pro", + "children": [], + "metrics": { + "views": 2800, + "visitors": 2240 + } + }, + { + "url": "https://apple.com/vision-pro", + "title": "Apple Vision Pro", + "children": [], + "metrics": { + "views": 2200, + "visitors": 1760 + } + }, + { + "url": "https://apple.com/macbook-pro", + "title": "MacBook Pro", + "children": [], + "metrics": { + "views": 1600, + "visitors": 1280 + } + } + ], + "metrics": { + "views": 8500, + "visitors": 6800 + } + }, + { + "url": "https://tesla.com", + "title": "Tesla", + "children": [ + { + "url": "https://tesla.com/cybertruck", + "title": "Cybertruck", + "children": [], + "metrics": { + "views": 1800, + "visitors": 1440 + } + }, + { + "url": "https://tesla.com/model-s", + "title": "Model S", + "children": [], + "metrics": { + "views": 1200, + "visitors": 960 + } + } + ], + "metrics": { + "views": 4200, + "visitors": 3360 + } + }, + { + "url": "https://amazon.com", + "title": "Amazon", + "children": [ + { + "url": "https://amazon.com/dp/B09G9CJM1Z", + "title": "Brother Laser Printer", + "children": [], + "metrics": { + "views": 1500, + "visitors": 1200 + } + } + ], + "metrics": { + "views": 3800, + "visitors": 3040 + } + }, + { + "url": "https://github.com", + "title": "GitHub", + "children": [ + { + "url": "https://github.com/openai/chatgpt", + "title": "ChatGPT Repository", + "children": [], + "metrics": { + "views": 1100, + "visitors": 880 + } + }, + { + "url": "https://github.com/vercel/next.js", + "title": "Next.js", + "children": [], + "metrics": { + "views": 900, + "visitors": 720 + } + } + ], + "metrics": { + "views": 3200, + "visitors": 2560 + } + }, + { + "url": "https://microsoft.com", + "title": "Microsoft", + "children": [ + { + "url": "https://microsoft.com/en-us/microsoft-365", + "title": "Microsoft 365", + "children": [], + "metrics": { + "views": 800, + "visitors": 640 + } + }, + { + "url": "https://microsoft.com/surface", + "title": "Surface", + "children": [], + "metrics": { + "views": 600, + "visitors": 480 + } + } + ], + "metrics": { + "views": 2800, + "visitors": 2240 + } + }, + { + "url": "https://google.com", + "title": "Google", + "children": [ + { + "url": "https://store.google.com/product/pixel_8_pro", + "title": "Pixel 8 Pro", + "children": [], + "metrics": { + "views": 1000, + "visitors": 800 + } + } + ], + "metrics": { + "views": 2600, + "visitors": 2080 + } + }, + { + "url": "https://samsung.com", + "title": "Samsung", + "children": [ + { + "url": "https://samsung.com/smartphones/galaxy-s24-ultra", + "title": "Galaxy S24 Ultra", + "children": [], + "metrics": { + "views": 700, + "visitors": 560 + } + } + ], + "metrics": { + "views": 2200, + "visitors": 1760 + } + }, + { + "url": "https://frame.work", + "title": "Framework", + "children": [], + "metrics": { + "views": 1800, + "visitors": 1440 + } + }, + { + "url": "https://nothing.tech", + "title": "Nothing", + "children": [], + "metrics": { + "views": 1600, + "visitors": 1280 + } + }, + { + "url": "https://store.steampowered.com", + "title": "Steam", + "children": [ + { + "url": "https://store.steampowered.com/steamdeck", + "title": "Steam Deck", + "children": [], + "metrics": { + "views": 500, + "visitors": 400 + } + } + ], + "metrics": { + "views": 1400, + "visitors": 1120 + } + }, + { + "url": "https://netflix.com", + "title": "Netflix", + "children": [], + "metrics": { + "views": 1200, + "visitors": 960 + } + }, + { + "url": "https://openai.com", + "title": "OpenAI", + "children": [], + "metrics": { + "views": 1100, + "visitors": 880 + } + }, + { + "url": "https://anthropic.com", + "title": "Anthropic", + "children": [], + "metrics": { + "views": 900, + "visitors": 720 + } + }, + { + "url": "https://meta.com", + "title": "Meta", + "children": [], + "metrics": { + "views": 800, + "visitors": 640 + } + }, + { + "url": "https://sony.com", + "title": "Sony", + "children": [], + "metrics": { + "views": 700, + "visitors": 560 + } + }, + { + "url": "https://dyson.com", + "title": "Dyson", + "children": [], + "metrics": { + "views": 600, + "visitors": 480 + } + }, + { + "url": "https://bestbuy.com", + "title": "Best Buy", + "children": [], + "metrics": { + "views": 500, + "visitors": 400 + } + }, + { + "url": "https://rabbit.tech", + "title": "Rabbit", + "children": [], + "metrics": { + "views": 400, + "visitors": 320 + } + }, + { + "url": "https://arc.net", + "title": "Arc Browser", + "children": [], + "metrics": { + "views": 350, + "visitors": 280 + } + }, + { + "url": "https://raspberrypi.com", + "title": "Raspberry Pi", + "children": [], + "metrics": { + "views": 300, + "visitors": 240 + } + } +] \ 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..19b90a095518 --- /dev/null +++ b/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-file-downloads.json @@ -0,0 +1,177 @@ +[ + { + "fileName": "cybertruck-safety-report-2024.pdf", + "filePath": "/downloads/reports/cybertruck-safety-report-2024.pdf", + "metrics": { + "downloads": 4200 + } + }, + { + "fileName": "best-tech-products-2024.pdf", + "filePath": "/downloads/guides/best-tech-products-2024.pdf", + "metrics": { + "downloads": 3800 + } + }, + { + "fileName": "apple-vision-pro-specs.pdf", + "filePath": "/downloads/specs/apple-vision-pro-specs.pdf", + "metrics": { + "downloads": 3500 + } + }, + { + "fileName": "printer-buying-guide-2024.pdf", + "filePath": "/downloads/guides/printer-buying-guide-2024.pdf", + "metrics": { + "downloads": 3200 + } + }, + { + "fileName": "twitter-x-rebrand-timeline.pdf", + "filePath": "/downloads/infographics/twitter-x-rebrand-timeline.pdf", + "metrics": { + "downloads": 2800 + } + }, + { + "fileName": "framework-laptop-repair-guide.pdf", + "filePath": "/downloads/manuals/framework-laptop-repair-guide.pdf", + "metrics": { + "downloads": 2600 + } + }, + { + "fileName": "steam-deck-oled-comparison.xlsx", + "filePath": "/downloads/data/steam-deck-oled-comparison.xlsx", + "metrics": { + "downloads": 2400 + } + }, + { + "fileName": "ai-tools-comparison-2024.pdf", + "filePath": "/downloads/reports/ai-tools-comparison-2024.pdf", + "metrics": { + "downloads": 2200 + } + }, + { + "fileName": "smart-home-setup-guide.pdf", + "filePath": "/downloads/guides/smart-home-setup-guide.pdf", + "metrics": { + "downloads": 2000 + } + }, + { + "fileName": "pixel-camera-samples.zip", + "filePath": "/downloads/media/pixel-camera-samples.zip", + "metrics": { + "downloads": 1800 + } + }, + { + "fileName": "m3-macbook-benchmarks.xlsx", + "filePath": "/downloads/data/m3-macbook-benchmarks.xlsx", + "metrics": { + "downloads": 1600 + } + }, + { + "fileName": "netflix-sharing-workarounds.pdf", + "filePath": "/downloads/guides/netflix-sharing-workarounds.pdf", + "metrics": { + "downloads": 1500 + } + }, + { + "fileName": "browser-privacy-comparison.pdf", + "filePath": "/downloads/reports/browser-privacy-comparison.pdf", + "metrics": { + "downloads": 1400 + } + }, + { + "fileName": "raspberry-pi-5-pinout.pdf", + "filePath": "/downloads/docs/raspberry-pi-5-pinout.pdf", + "metrics": { + "downloads": 1200 + } + }, + { + "fileName": "windows-11-debloat-script.ps1", + "filePath": "/downloads/scripts/windows-11-debloat-script.ps1", + "metrics": { + "downloads": 1100 + } + }, + { + "fileName": "ev-comparison-chart-2024.pdf", + "filePath": "/downloads/infographics/ev-comparison-chart-2024.pdf", + "metrics": { + "downloads": 1000 + } + }, + { + "fileName": "vr-headset-specs-comparison.xlsx", + "filePath": "/downloads/data/vr-headset-specs-comparison.xlsx", + "metrics": { + "downloads": 900 + } + }, + { + "fileName": "smart-tv-ad-blocking-guide.pdf", + "filePath": "/downloads/guides/smart-tv-ad-blocking-guide.pdf", + "metrics": { + "downloads": 850 + } + }, + { + "fileName": "tech-company-revenue-2024.csv", + "filePath": "/downloads/data/tech-company-revenue-2024.csv", + "metrics": { + "downloads": 800 + } + }, + { + "fileName": "password-manager-comparison.pdf", + "filePath": "/downloads/reports/password-manager-comparison.pdf", + "metrics": { + "downloads": 750 + } + }, + { + "fileName": "arc-browser-shortcuts.pdf", + "filePath": "/downloads/cheatsheets/arc-browser-shortcuts.pdf", + "metrics": { + "downloads": 700 + } + }, + { + "fileName": "github-copilot-alternatives.pdf", + "filePath": "/downloads/guides/github-copilot-alternatives.pdf", + "metrics": { + "downloads": 650 + } + }, + { + "fileName": "mastodon-migration-guide.pdf", + "filePath": "/downloads/guides/mastodon-migration-guide.pdf", + "metrics": { + "downloads": 600 + } + }, + { + "fileName": "dall-e-prompts-collection.txt", + "filePath": "/downloads/resources/dall-e-prompts-collection.txt", + "metrics": { + "downloads": 550 + } + }, + { + "fileName": "tech-glossary-2024.pdf", + "filePath": "/downloads/references/tech-glossary-2024.pdf", + "metrics": { + "downloads": 500 + } + } +] \ No newline at end of file diff --git a/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-locations.json b/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-locations.json new file mode 100644 index 000000000000..c667a9a30ef5 --- /dev/null +++ b/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-locations.json @@ -0,0 +1,392 @@ +[ + { + "country": "United States", + "countryCode": "US", + "flag": "🇺🇸", + "metrics": { + "views": 18000, + "visitors": 12600, + "bounceRate": 28, + "timeOnSite": 320, + "comments": 810, + "likes": 1764 + } + }, + { + "country": "United Kingdom", + "countryCode": "GB", + "flag": "🇬🇧", + "metrics": { + "views": 6800, + "visitors": 4760, + "bounceRate": 32, + "timeOnSite": 290, + "comments": 306, + "likes": 666 + } + }, + { + "country": "Canada", + "countryCode": "CA", + "flag": "🇨🇦", + "metrics": { + "views": 4200, + "visitors": 2940, + "bounceRate": 35, + "timeOnSite": 270, + "comments": 189, + "likes": 412 + } + }, + { + "country": "Germany", + "countryCode": "DE", + "flag": "🇩🇪", + "metrics": { + "views": 3600, + "visitors": 2520, + "bounceRate": 34, + "timeOnSite": 280, + "comments": 162, + "likes": 353 + } + }, + { + "country": "Japan", + "countryCode": "JP", + "flag": "🇯🇵", + "metrics": { + "views": 3200, + "visitors": 2240, + "bounceRate": 38, + "timeOnSite": 250, + "comments": 144, + "likes": 314 + } + }, + { + "country": "Australia", + "countryCode": "AU", + "flag": "🇦🇺", + "metrics": { + "views": 2800, + "visitors": 1960, + "bounceRate": 36, + "timeOnSite": 260, + "comments": 126, + "likes": 274 + } + }, + { + "country": "France", + "countryCode": "FR", + "flag": "🇫🇷", + "metrics": { + "views": 2400, + "visitors": 1680, + "bounceRate": 37, + "timeOnSite": 255, + "comments": 108, + "likes": 235 + } + }, + { + "country": "India", + "countryCode": "IN", + "flag": "🇮🇳", + "metrics": { + "views": 2200, + "visitors": 1540, + "bounceRate": 42, + "timeOnSite": 220, + "comments": 99, + "likes": 216 + } + }, + { + "country": "Netherlands", + "countryCode": "NL", + "flag": "🇳🇱", + "metrics": { + "views": 1800, + "visitors": 1260, + "bounceRate": 30, + "timeOnSite": 300, + "comments": 81, + "likes": 176 + } + }, + { + "country": "Sweden", + "countryCode": "SE", + "flag": "🇸🇪", + "metrics": { + "views": 1600, + "visitors": 1120, + "bounceRate": 29, + "timeOnSite": 310, + "comments": 72, + "likes": 157 + } + }, + { + "country": "Italy", + "countryCode": "IT", + "flag": "🇮🇹", + "metrics": { + "views": 1500, + "visitors": 1050, + "bounceRate": 40, + "timeOnSite": 240, + "comments": 68, + "likes": 147 + } + }, + { + "country": "South Korea", + "countryCode": "KR", + "flag": "🇰🇷", + "metrics": { + "views": 1400, + "visitors": 980, + "bounceRate": 33, + "timeOnSite": 285, + "comments": 63, + "likes": 137 + } + }, + { + "country": "Spain", + "countryCode": "ES", + "flag": "🇪🇸", + "metrics": { + "views": 1300, + "visitors": 910, + "bounceRate": 41, + "timeOnSite": 235, + "comments": 59, + "likes": 127 + } + }, + { + "country": "Brazil", + "countryCode": "BR", + "flag": "🇧🇷", + "metrics": { + "views": 1200, + "visitors": 840, + "bounceRate": 43, + "timeOnSite": 215, + "comments": 54, + "likes": 118 + } + }, + { + "country": "Switzerland", + "countryCode": "CH", + "flag": "🇨🇭", + "metrics": { + "views": 1100, + "visitors": 770, + "bounceRate": 31, + "timeOnSite": 295, + "comments": 50, + "likes": 108 + } + }, + { + "country": "Norway", + "countryCode": "NO", + "flag": "🇳🇴", + "metrics": { + "views": 1000, + "visitors": 700, + "bounceRate": 32, + "timeOnSite": 290, + "comments": 45, + "likes": 98 + } + }, + { + "country": "Denmark", + "countryCode": "DK", + "flag": "🇩🇰", + "metrics": { + "views": 900, + "visitors": 630, + "bounceRate": 33, + "timeOnSite": 285, + "comments": 41, + "likes": 88 + } + }, + { + "country": "Belgium", + "countryCode": "BE", + "flag": "🇧🇪", + "metrics": { + "views": 850, + "visitors": 595, + "bounceRate": 35, + "timeOnSite": 275, + "comments": 38, + "likes": 83 + } + }, + { + "country": "Poland", + "countryCode": "PL", + "flag": "🇵🇱", + "metrics": { + "views": 800, + "visitors": 560, + "bounceRate": 39, + "timeOnSite": 245, + "comments": 36, + "likes": 78 + } + }, + { + "country": "Finland", + "countryCode": "FI", + "flag": "🇫🇮", + "metrics": { + "views": 750, + "visitors": 525, + "bounceRate": 30, + "timeOnSite": 305, + "comments": 34, + "likes": 74 + } + }, + { + "country": "Austria", + "countryCode": "AT", + "flag": "🇦🇹", + "metrics": { + "views": 700, + "visitors": 490, + "bounceRate": 36, + "timeOnSite": 265, + "comments": 32, + "likes": 69 + } + }, + { + "country": "Singapore", + "countryCode": "SG", + "flag": "🇸🇬", + "metrics": { + "views": 650, + "visitors": 455, + "bounceRate": 28, + "timeOnSite": 315, + "comments": 29, + "likes": 64 + } + }, + { + "country": "Ireland", + "countryCode": "IE", + "flag": "🇮🇪", + "metrics": { + "views": 600, + "visitors": 420, + "bounceRate": 34, + "timeOnSite": 280, + "comments": 27, + "likes": 59 + } + }, + { + "country": "New Zealand", + "countryCode": "NZ", + "flag": "🇳🇿", + "metrics": { + "views": 550, + "visitors": 385, + "bounceRate": 37, + "timeOnSite": 255, + "comments": 25, + "likes": 54 + } + }, + { + "country": "Hong Kong", + "countryCode": "HK", + "flag": "🇭🇰", + "metrics": { + "views": 500, + "visitors": 350, + "bounceRate": 31, + "timeOnSite": 295, + "comments": 23, + "likes": 49 + } + }, + { + "country": "Israel", + "countryCode": "IL", + "flag": "🇮🇱", + "metrics": { + "views": 450, + "visitors": 315, + "bounceRate": 33, + "timeOnSite": 285, + "comments": 20, + "likes": 44 + } + }, + { + "country": "Mexico", + "countryCode": "MX", + "flag": "🇲🇽", + "metrics": { + "views": 400, + "visitors": 280, + "bounceRate": 44, + "timeOnSite": 210, + "comments": 18, + "likes": 39 + } + }, + { + "country": "Czech Republic", + "countryCode": "CZ", + "flag": "🇨🇿", + "metrics": { + "views": 350, + "visitors": 245, + "bounceRate": 38, + "timeOnSite": 250, + "comments": 16, + "likes": 34 + } + }, + { + "country": "Portugal", + "countryCode": "PT", + "flag": "🇵🇹", + "metrics": { + "views": 300, + "visitors": 210, + "bounceRate": 40, + "timeOnSite": 240, + "comments": 14, + "likes": 29 + } + }, + { + "country": "Taiwan", + "countryCode": "TW", + "flag": "🇹🇼", + "metrics": { + "views": 250, + "visitors": 175, + "bounceRate": 35, + "timeOnSite": 270, + "comments": 11, + "likes": 25 + } + } +] \ No newline at end of file diff --git a/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-postsAndPages.json b/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-postsAndPages.json new file mode 100644 index 000000000000..64ee667d78ca --- /dev/null +++ b/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-postsAndPages.json @@ -0,0 +1,407 @@ +[ + { + "title": "Best printer 2025: just buy a Brother laser printer, the winner is clear, middle finger in the air", + "postID": "1", + "type": "post", + "author": "Alex Johnson", + "date": "2024-11-20T00:00:00Z", + "metrics": { + "views": 5800, + "comments": 289, + "likes": 1200, + "visitors": 4060, + "bounceRate": 25, + "timeOnSite": 420 + } + }, + { + "title": "I Replaced Every Component in My Framework Laptop Until It Became a Desktop", + "postID": "2", + "type": "post", + "author": "Alex Johnson", + "date": "2024-11-18T00:00:00Z", + "metrics": { + "views": 2500, + "comments": 125, + "likes": 450, + "visitors": 1750, + "bounceRate": 35, + "timeOnSite": 240 + } + }, + { + "title": "The Cybertruck's Frunk Won't Stop Eating Fingers", + "postID": "3", + "type": "post", + "author": "Chloe Zhang", + "date": "2024-11-15T00:00:00Z", + "metrics": { + "views": 2000, + "comments": 98, + "likes": 380, + "visitors": 1400, + "bounceRate": 40, + "timeOnSite": 210 + } + }, + { + "title": "Matter Smart Home Protocol Still Doesn't Matter: A Year Later", + "postID": "4", + "type": "post", + "author": "Jordan Davis", + "date": "2024-11-12T00:00:00Z", + "metrics": { + "views": 1800, + "comments": 85, + "likes": 320, + "visitors": 1260, + "bounceRate": 38, + "timeOnSite": 225 + } + }, + { + "title": "Trackpad Alignment on the New MacBook Pro: A 10,000 Word Investigation", + "postID": "5", + "type": "post", + "author": "Sofia Rodriguez", + "date": "2024-11-10T00:00:00Z", + "metrics": { + "views": 1600, + "comments": 72, + "likes": 280, + "visitors": 1120, + "bounceRate": 42, + "timeOnSite": 195 + } + }, + { + "title": "Nothing Phone (2a): Now With 50% More Nothing", + "postID": "6", + "type": "post", + "author": "Morgan Smith", + "date": "2024-11-08T00:00:00Z", + "metrics": { + "views": 1400, + "comments": 65, + "likes": 245, + "visitors": 980, + "bounceRate": 45, + "timeOnSite": 180 + } + }, + { + "title": "Samsung's Moon Photography Is Real* (*Terms and Conditions Apply)", + "postID": "7", + "type": "post", + "author": "Emma Thompson", + "date": "2024-11-05T00:00:00Z", + "metrics": { + "views": 1200, + "comments": 58, + "likes": 210, + "visitors": 840, + "bounceRate": 48, + "timeOnSite": 165 + } + }, + { + "title": "The Steam Deck OLED Screen Is So Good I Licked It", + "postID": "8", + "type": "post", + "author": "Riley Martinez", + "date": "2024-11-03T00:00:00Z", + "metrics": { + "views": 1100, + "comments": 52, + "likes": 195, + "visitors": 770, + "bounceRate": 43, + "timeOnSite": 190 + } + }, + { + "title": "Meta's VR Legs Update: They're Still Not Real", + "postID": "9", + "type": "post", + "author": "Riley Martinez", + "date": "2024-10-30T00:00:00Z", + "metrics": { + "views": 1000, + "comments": 48, + "likes": 175, + "visitors": 700, + "bounceRate": 46, + "timeOnSite": 175 + } + }, + { + "title": "About The Verge", + "postID": "9991", + "type": "page", + "author": "Alex Johnson", + "date": null, + "metrics": { + "views": 900, + "comments": 0, + "likes": 25, + "visitors": 720, + "bounceRate": 60, + "timeOnSite": 90 + } + }, + { + "title": "Microsoft's New AI Can Predict When You'll Rage Quit Excel", + "postID": "10", + "type": "post", + "author": "Jamie Lee", + "date": "2024-10-28T00:00:00Z", + "metrics": { + "views": 900, + "comments": 42, + "likes": 160, + "visitors": 630, + "bounceRate": 50, + "timeOnSite": 155 + } + }, + { + "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", + "date": "2024-10-25T00:00:00Z", + "metrics": { + "views": 850, + "comments": 38, + "likes": 150, + "visitors": 595, + "bounceRate": 44, + "timeOnSite": 185 + } + }, + { + "title": "Google's Pixel 8 Pro Has Too Many Cameras (I Counted)", + "postID": "12", + "type": "post", + "author": "Avery Taylor", + "date": "2024-10-22T00:00:00Z", + "metrics": { + "views": 750, + "comments": 32, + "likes": 130, + "visitors": 525, + "bounceRate": 52, + "timeOnSite": 145 + } + }, + { + "title": "This Smart Toaster Has DRM and I'm Not Even Surprised Anymore", + "postID": "14", + "type": "post", + "author": "Jamie Lee", + "date": "2024-10-18T00:00:00Z", + "metrics": { + "views": 2100, + "comments": 102, + "likes": 420, + "visitors": 1470, + "bounceRate": 33, + "timeOnSite": 260 + } + }, + { + "title": "ChatGPT Convinced Me It Was Sentient (It Was Lying)", + "postID": "15", + "type": "post", + "author": "Emma Thompson", + "date": "2024-10-15T00:00:00Z", + "metrics": { + "views": 4500, + "comments": 225, + "likes": 890, + "visitors": 3150, + "bounceRate": 28, + "timeOnSite": 340 + } + }, + { + "title": "The $2000 Dyson Hair Dryer: A Scientific Analysis of My Poor Life Choices", + "postID": "16", + "type": "post", + "author": "Chloe Zhang", + "date": "2024-10-12T00:00:00Z", + "metrics": { + "views": 1800, + "comments": 89, + "likes": 340, + "visitors": 1260, + "bounceRate": 36, + "timeOnSite": 230 + } + }, + { + "title": "I Tried to Return a Smart TV That Shows Ads. Best Buy Laughed at Me", + "postID": "18", + "type": "post", + "author": "Avery Taylor", + "date": "2024-10-08T00:00:00Z", + "metrics": { + "views": 2800, + "comments": 142, + "likes": 560, + "visitors": 1960, + "bounceRate": 31, + "timeOnSite": 295 + } + }, + { + "title": "Sony's New $200 Controller Has a Screen Nobody Asked For", + "postID": "19", + "type": "post", + "author": "Riley Martinez", + "date": "2024-10-05T00:00:00Z", + "metrics": { + "views": 1500, + "comments": 75, + "likes": 290, + "visitors": 1050, + "bounceRate": 39, + "timeOnSite": 205 + } + }, + { + "title": "The M3 MacBook Air Is Faster Than My Personality", + "postID": "20", + "type": "post", + "author": "Alex Johnson", + "date": "2024-10-02T00:00:00Z", + "metrics": { + "views": 3800, + "comments": 189, + "likes": 760, + "visitors": 2660, + "bounceRate": 26, + "timeOnSite": 320 + } + }, + { + "title": "Netflix Password Sharing Crackdown: A Love Story", + "postID": "21", + "type": "post", + "author": "Jordan Davis", + "date": "2024-09-30T00:00:00Z", + "metrics": { + "views": 5200, + "comments": 260, + "likes": 1040, + "visitors": 3640, + "bounceRate": 24, + "timeOnSite": 360 + } + }, + { + "title": "The Rabbit R1 Is Just an Android App in a Tiny Orange Box", + "postID": "22", + "type": "post", + "author": "Sofia Rodriguez", + "date": "2024-09-28T00:00:00Z", + "metrics": { + "views": 2300, + "comments": 115, + "likes": 460, + "visitors": 1610, + "bounceRate": 34, + "timeOnSite": 250 + } + }, + { + "title": "Contact Us", + "postID": "9992", + "type": "page", + "author": "Chloe Zhang", + "date": null, + "metrics": { + "views": 500, + "comments": 0, + "likes": 10, + "visitors": 400, + "bounceRate": 65, + "timeOnSite": 75 + } + }, + { + "title": "Newsletter Signup", + "postID": "9993", + "type": "page", + "author": "Jordan Davis", + "date": null, + "metrics": { + "views": 400, + "comments": 0, + "likes": 15, + "visitors": 320, + "bounceRate": 55, + "timeOnSite": 120 + } + }, + { + "title": "Privacy Policy", + "postID": "9994", + "type": "page", + "author": "Sofia Rodriguez", + "date": null, + "metrics": { + "views": 250, + "comments": 0, + "likes": 0, + "visitors": 200, + "bounceRate": 70, + "timeOnSite": 60 + } + }, + { + "title": "Terms of Service", + "postID": "9995", + "type": "page", + "author": "Morgan Smith", + "date": null, + "metrics": { + "views": 180, + "comments": 0, + "likes": 0, + "visitors": 144, + "bounceRate": 72, + "timeOnSite": 55 + } + }, + { + "title": "Careers", + "postID": "9996", + "type": "page", + "author": "Emma Thompson", + "date": null, + "metrics": { + "views": 320, + "comments": 0, + "likes": 8, + "visitors": 256, + "bounceRate": 58, + "timeOnSite": 95 + } + }, + { + "title": "Advertise With Us", + "postID": "9997", + "type": "page", + "author": "Quinn Anderson", + "date": null, + "metrics": { + "views": 220, + "comments": 0, + "likes": 5, + "visitors": 176, + "bounceRate": 62, + "timeOnSite": 85 + } + } +] diff --git a/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-referrers.json b/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-referrers.json new file mode 100644 index 000000000000..fde5413a0299 --- /dev/null +++ b/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-referrers.json @@ -0,0 +1,370 @@ +[ + { + "name": "Google Search", + "domain": "google.com", + "iconURL": "https://www.google.com/favicon.ico", + "children": [ + { + "name": "best laptop 2024", + "domain": "google.com", + "iconURL": "https://www.google.com/favicon.ico", + "children": [], + "metrics": { + "views": 1800, + "visitors": 1440, + "bounceRate": 18, + "timeOnSite": 380, + "comments": 72, + "likes": 165 + } + }, + { + "name": "cybertruck problems", + "domain": "google.com", + "iconURL": "https://www.google.com/favicon.ico", + "children": [], + "metrics": { + "views": 1500, + "visitors": 1200, + "bounceRate": 22, + "timeOnSite": 340, + "comments": 58, + "likes": 132 + } + }, + { + "name": "apple vision pro review", + "domain": "google.com", + "iconURL": "https://www.google.com/favicon.ico", + "children": [], + "metrics": { + "views": 1200, + "visitors": 960, + "bounceRate": 25, + "timeOnSite": 310, + "comments": 48, + "likes": 108 + } + } + ], + "metrics": { + "views": 8500, + "visitors": 6800, + "bounceRate": 20, + "timeOnSite": 350, + "comments": 340, + "likes": 780 + } + }, + { + "name": "Twitter/X", + "domain": "twitter.com", + "iconURL": "https://abs.twimg.com/favicons/twitter.3.ico", + "children": [ + { + "name": "@mkbhd shared your article", + "domain": "twitter.com", + "iconURL": "https://abs.twimg.com/favicons/twitter.3.ico", + "children": [], + "metrics": { + "views": 2200, + "visitors": 1760, + "bounceRate": 28, + "timeOnSite": 260, + "comments": 88, + "likes": 198 + } + }, + { + "name": "@verge tweet thread", + "domain": "twitter.com", + "iconURL": "https://abs.twimg.com/favicons/twitter.3.ico", + "children": [], + "metrics": { + "views": 1850, + "visitors": 1480, + "bounceRate": 30, + "timeOnSite": 240, + "comments": 74, + "likes": 167 + } + }, + { + "name": "#TechTwitter discussion", + "domain": "twitter.com", + "iconURL": "https://abs.twimg.com/favicons/twitter.3.ico", + "children": [], + "metrics": { + "views": 950, + "visitors": 760, + "bounceRate": 35, + "timeOnSite": 200, + "comments": 38, + "likes": 86 + } + } + ], + "metrics": { + "views": 6200, + "visitors": 4960, + "bounceRate": 32, + "timeOnSite": 230, + "comments": 248, + "likes": 558 + } + }, + { + "name": "Reddit", + "domain": "reddit.com", + "iconURL": "https://www.redditstatic.com/desktop2x/img/favicon/favicon-32x32.png", + "children": [ + { + "name": "r/technology", + "domain": "reddit.com", + "iconURL": "https://www.redditstatic.com/desktop2x/img/favicon/favicon-32x32.png", + "children": [], + "metrics": { + "views": 1600, + "visitors": 1280, + "bounceRate": 26, + "timeOnSite": 320, + "comments": 96, + "likes": 224 + } + }, + { + "name": "r/gadgets", + "domain": "reddit.com", + "iconURL": "https://www.redditstatic.com/desktop2x/img/favicon/favicon-32x32.png", + "children": [], + "metrics": { + "views": 1200, + "visitors": 960, + "bounceRate": 28, + "timeOnSite": 290, + "comments": 72, + "likes": 168 + } + }, + { + "name": "r/apple", + "domain": "reddit.com", + "iconURL": "https://www.redditstatic.com/desktop2x/img/favicon/favicon-32x32.png", + "children": [], + "metrics": { + "views": 800, + "visitors": 640, + "bounceRate": 24, + "timeOnSite": 340, + "comments": 48, + "likes": 112 + } + } + ], + "metrics": { + "views": 4800, + "visitors": 3840, + "bounceRate": 27, + "timeOnSite": 310, + "comments": 288, + "likes": 672 + } + }, + { + "name": "Hacker News", + "domain": "news.ycombinator.com", + "iconURL": "https://news.ycombinator.com/favicon.ico", + "children": [], + "metrics": { + "views": 3200, + "visitors": 2560, + "bounceRate": 15, + "timeOnSite": 420, + "comments": 192, + "likes": 448 + } + }, + { + "name": "LinkedIn", + "domain": "linkedin.com", + "iconURL": "https://static.licdn.com/sc/h/al2o9zrvru7aqj8e1x2rzsrca", + "children": [], + "metrics": { + "views": 1800, + "visitors": 1440, + "bounceRate": 35, + "timeOnSite": 250, + "comments": 72, + "likes": 162 + } + }, + { + "name": "YouTube", + "domain": "youtube.com", + "iconURL": "https://www.youtube.com/s/desktop/8a0c81e8/img/favicon_32x32.png", + "children": [ + { + "name": "Tech review channels", + "domain": "youtube.com", + "iconURL": "https://www.youtube.com/s/desktop/8a0c81e8/img/favicon_32x32.png", + "children": [], + "metrics": { + "views": 850, + "visitors": 680, + "bounceRate": 40, + "timeOnSite": 180, + "comments": 34, + "likes": 77 + } + } + ], + "metrics": { + "views": 1600, + "visitors": 1280, + "bounceRate": 42, + "timeOnSite": 170, + "comments": 64, + "likes": 144 + } + }, + { + "name": "Ars Technica", + "domain": "arstechnica.com", + "iconURL": "https://arstechnica.com/favicon.ico", + "children": [], + "metrics": { + "views": 1400, + "visitors": 1120, + "bounceRate": 20, + "timeOnSite": 380, + "comments": 84, + "likes": 196 + } + }, + { + "name": "The Register", + "domain": "theregister.com", + "iconURL": "https://www.theregister.com/favicon.ico", + "children": [], + "metrics": { + "views": 1200, + "visitors": 960, + "bounceRate": 18, + "timeOnSite": 390, + "comments": 72, + "likes": 168 + } + }, + { + "name": "Facebook", + "domain": "facebook.com", + "iconURL": "https://static.xx.fbcdn.net/rsrc.php/yD/r/d4ZIVX-5C-b.ico", + "children": [], + "metrics": { + "views": 1100, + "visitors": 880, + "bounceRate": 45, + "timeOnSite": 150, + "comments": 44, + "likes": 99 + } + }, + { + "name": "Mastodon", + "domain": "mastodon.social", + "iconURL": "https://mastodon.social/favicon.ico", + "children": [], + "metrics": { + "views": 900, + "visitors": 720, + "bounceRate": 25, + "timeOnSite": 280, + "comments": 54, + "likes": 126 + } + }, + { + "name": "GitHub", + "domain": "github.com", + "iconURL": "https://github.githubassets.com/favicons/favicon.svg", + "children": [], + "metrics": { + "views": 800, + "visitors": 640, + "bounceRate": 30, + "timeOnSite": 300, + "comments": 48, + "likes": 112 + } + }, + { + "name": "Lobsters", + "domain": "lobste.rs", + "iconURL": "https://lobste.rs/favicon.ico", + "children": [], + "metrics": { + "views": 600, + "visitors": 480, + "bounceRate": 22, + "timeOnSite": 360, + "comments": 36, + "likes": 84 + } + }, + { + "name": "Discord", + "domain": "discord.com", + "iconURL": "https://discord.com/assets/favicon.ico", + "children": [], + "metrics": { + "views": 500, + "visitors": 400, + "bounceRate": 38, + "timeOnSite": 200, + "comments": 20, + "likes": 45 + } + }, + { + "name": "Slack", + "domain": "slack.com", + "iconURL": "https://a.slack-edge.com/80588/marketing/img/meta/favicon-32.png", + "children": [], + "metrics": { + "views": 400, + "visitors": 320, + "bounceRate": 35, + "timeOnSite": 220, + "comments": 16, + "likes": 36 + } + }, + { + "name": "Direct/Bookmarks", + "domain": "direct", + "iconURL": null, + "children": [], + "metrics": { + "views": 3500, + "visitors": 2800, + "bounceRate": 15, + "timeOnSite": 400, + "comments": 140, + "likes": 315 + } + }, + { + "name": "Email Newsletter", + "domain": "email", + "iconURL": null, + "children": [], + "metrics": { + "views": 2800, + "visitors": 2240, + "bounceRate": 12, + "timeOnSite": 440, + "comments": 112, + "likes": 252 + } + } +] 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..66166ec0d0b5 --- /dev/null +++ b/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-search-terms.json @@ -0,0 +1,212 @@ +[ + { + "term": "apple vision pro review", + "metrics": { + "views": 4200, + "visitors": 3360 + } + }, + { + "term": "best laptop 2024", + "metrics": { + "views": 3800, + "visitors": 3040 + } + }, + { + "term": "cybertruck problems", + "metrics": { + "views": 3500, + "visitors": 2800 + } + }, + { + "term": "nothing phone review", + "metrics": { + "views": 2800, + "visitors": 2240 + } + }, + { + "term": "macbook pro m3 max", + "metrics": { + "views": 2600, + "visitors": 2080 + } + }, + { + "term": "chatgpt vs claude", + "metrics": { + "views": 2400, + "visitors": 1920 + } + }, + { + "term": "steam deck oled", + "metrics": { + "views": 2200, + "visitors": 1760 + } + }, + { + "term": "pixel 8 pro camera test", + "metrics": { + "views": 2000, + "visitors": 1600 + } + }, + { + "term": "framework laptop review", + "metrics": { + "views": 1800, + "visitors": 1440 + } + }, + { + "term": "meta vr legs", + "metrics": { + "views": 1600, + "visitors": 1280 + } + }, + { + "term": "twitter x rebrand", + "metrics": { + "views": 1500, + "visitors": 1200 + } + }, + { + "term": "smart home matter protocol", + "metrics": { + "views": 1400, + "visitors": 1120 + } + }, + { + "term": "dyson zone headphones", + "metrics": { + "views": 1300, + "visitors": 1040 + } + }, + { + "term": "samsung moon photography fake", + "metrics": { + "views": 1200, + "visitors": 960 + } + }, + { + "term": "rabbit r1 ai device", + "metrics": { + "views": 1100, + "visitors": 880 + } + }, + { + "term": "best ai tools 2024", + "metrics": { + "views": 1000, + "visitors": 800 + } + }, + { + "term": "tesla fsd beta", + "metrics": { + "views": 950, + "visitors": 760 + } + }, + { + "term": "apple usb-c iphone", + "metrics": { + "views": 900, + "visitors": 720 + } + }, + { + "term": "microsoft excel ai features", + "metrics": { + "views": 850, + "visitors": 680 + } + }, + { + "term": "netflix password sharing", + "metrics": { + "views": 800, + "visitors": 640 + } + }, + { + "term": "smart tv ads blocking", + "metrics": { + "views": 750, + "visitors": 600 + } + }, + { + "term": "sony dualsense edge", + "metrics": { + "views": 700, + "visitors": 560 + } + }, + { + "term": "brother laser printer recommendation", + "metrics": { + "views": 650, + "visitors": 520 + } + }, + { + "term": "github copilot alternatives", + "metrics": { + "views": 600, + "visitors": 480 + } + }, + { + "term": "mastodon vs twitter", + "metrics": { + "views": 550, + "visitors": 440 + } + }, + { + "term": "arc browser review", + "metrics": { + "views": 500, + "visitors": 400 + } + }, + { + "term": "raspberry pi 5 specs", + "metrics": { + "views": 450, + "visitors": 360 + } + }, + { + "term": "windows 11 ads remove", + "metrics": { + "views": 400, + "visitors": 320 + } + }, + { + "term": "best password manager 2024", + "metrics": { + "views": 350, + "visitors": 280 + } + }, + { + "term": "dall-e 3 vs midjourney", + "metrics": { + "views": 300, + "visitors": 240 + } + } +] \ 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..45e2fc01e718 --- /dev/null +++ b/Modules/Sources/JetpackStats/Resources/Mocks/HistoricalData/historical-videos.json @@ -0,0 +1,182 @@ +[ + { + "title": "Apple Vision Pro Unboxing: The $3,500 Reality Check", + "postId": "11", + "videoURL": "https://example.com/videos/vision-pro-unboxing.mp4", + "metrics": { + "views": 8500, + "likes": 480 + } + }, + { + "title": "Cybertruck vs F-150 Lightning: Electric Truck Showdown", + "postId": "3", + "videoURL": "https://example.com/videos/truck-showdown.mp4", + "metrics": { + "views": 6200, + "likes": 350 + } + }, + { + "title": "Framework Laptop 16: The Ultimate Modular Machine", + "postId": "2", + "videoURL": "https://example.com/videos/framework-16.mp4", + "metrics": { + "views": 5800, + "likes": 320 + } + }, + { + "title": "Nothing Phone (2a) Review: Glyph Interface Deep Dive", + "postId": "6", + "videoURL": "https://example.com/videos/nothing-phone-2a.mp4", + "metrics": { + "views": 4500, + "likes": 250 + } + }, + { + "title": "Steam Deck OLED vs LCD: Side by Side Comparison", + "postId": "8", + "videoURL": "https://example.com/videos/steam-deck-comparison.mp4", + "metrics": { + "views": 4200, + "likes": 238 + } + }, + { + "title": "ChatGPT vs Claude: AI Assistant Battle", + "postId": "15", + "videoURL": "https://example.com/videos/ai-battle.mp4", + "metrics": { + "views": 3800, + "likes": 210 + } + }, + { + "title": "M3 MacBook Pro Performance Tests: Mind-Blowing Results", + "postId": "20", + "videoURL": "https://example.com/videos/m3-performance.mp4", + "metrics": { + "views": 3500, + "likes": 195 + } + }, + { + "title": "Pixel 8 Pro Camera Test: Moon Mode Investigation", + "postId": "12", + "videoURL": "https://example.com/videos/pixel-camera-test.mp4", + "metrics": { + "views": 3200, + "likes": 180 + } + }, + { + "title": "Twitter to X: The Rebrand Nobody Asked For", + "postId": "17", + "videoURL": "https://example.com/videos/twitter-x-rebrand.mp4", + "metrics": { + "views": 2800, + "likes": 156 + } + }, + { + "title": "Meta Quest 3 vs Apple Vision Pro: VR Headset Comparison", + "postId": "9", + "videoURL": "https://example.com/videos/vr-comparison.mp4", + "metrics": { + "views": 2600, + "likes": 145 + } + }, + { + "title": "The Smart Toaster That Requires a Subscription", + "postId": "14", + "videoURL": "https://example.com/videos/smart-toaster.mp4", + "metrics": { + "views": 2400, + "likes": 134 + } + }, + { + "title": "Brother Printer Setup: Why It Just Works", + "postId": "1", + "videoURL": "https://example.com/videos/brother-printer.mp4", + "metrics": { + "views": 2200, + "likes": 122 + } + }, + { + "title": "Rabbit R1 Teardown: It's Just Android", + "postId": "22", + "videoURL": "https://example.com/videos/rabbit-r1-teardown.mp4", + "metrics": { + "views": 2000, + "likes": 112 + } + }, + { + "title": "Sony DualSense Edge Controller Review", + "postId": "19", + "videoURL": "https://example.com/videos/dualsense-edge.mp4", + "metrics": { + "views": 1800, + "likes": 100 + } + }, + { + "title": "Matter Smart Home: One Year Later", + "postId": "4", + "videoURL": "https://example.com/videos/matter-update.mp4", + "metrics": { + "views": 1600, + "likes": 89 + } + }, + { + "title": "Dyson Zone: The $1000 Air Purifying Headphones", + "postId": "16", + "videoURL": "https://example.com/videos/dyson-zone.mp4", + "metrics": { + "views": 1400, + "likes": 78 + } + }, + { + "title": "Arc Browser: The Future of Web Browsing?", + "postId": "101", + "videoURL": "https://example.com/videos/arc-browser.mp4", + "metrics": { + "views": 1200, + "likes": 67 + } + }, + { + "title": "Raspberry Pi 5: Unboxing and First Boot", + "postId": "102", + "videoURL": "https://example.com/videos/pi5-unboxing.mp4", + "metrics": { + "views": 1000, + "likes": 56 + } + }, + { + "title": "Netflix Password Sharing: How They Caught Me", + "postId": "21", + "videoURL": "https://example.com/videos/netflix-sharing.mp4", + "metrics": { + "views": 900, + "likes": 50 + } + }, + { + "title": "Windows 11: Turning Off All The Ads", + "postId": "103", + "videoURL": "https://example.com/videos/windows11-ads.mp4", + "metrics": { + "views": 800, + "likes": 45 + } + } +] \ 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..f26ade8f98c5 --- /dev/null +++ b/Modules/Sources/JetpackStats/Resources/Mocks/Misc/post-details.json @@ -0,0 +1,520 @@ +{ + "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 + } + }, + "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": { + "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 + } + }, + "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": [ + { + "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": [ + ["2024-03-15", 45], + ["2024-03-16", 89], + ["2024-03-17", 124], + ["2024-03-18", 156], + ["2024-03-19", 178], + ["2024-03-20", 145], + ["2024-03-21", 134], + ["2024-03-22", 98], + ["2024-03-23", 87], + ["2024-03-24", 112], + ["2024-03-25", 134], + ["2024-03-26", 156], + ["2024-03-27", 123], + ["2024-03-28", 98], + ["2024-03-29", 76], + ["2024-03-30", 65], + ["2024-03-31", 67], + ["2024-04-01", 123], + ["2024-04-02", 145], + ["2024-04-03", 134], + ["2024-04-04", 112], + ["2024-04-05", 98], + ["2024-04-06", 87], + ["2024-04-07", 76], + ["2024-04-08", 134], + ["2024-04-09", 156], + ["2024-04-10", 145], + ["2024-04-11", 123], + ["2024-04-12", 112], + ["2024-04-13", 98], + ["2024-04-14", 89], + ["2024-04-15", 145], + ["2024-04-16", 167], + ["2024-04-17", 156], + ["2024-04-18", 134], + ["2024-04-19", 123], + ["2024-04-20", 112], + ["2024-04-21", 98], + ["2024-04-22", 134], + ["2024-04-23", 145], + ["2024-04-24", 156], + ["2024-04-25", 123], + ["2024-04-26", 112], + ["2024-04-27", 98], + ["2024-04-28", 89], + ["2024-04-29", 123], + ["2024-04-30", 134], + ["2024-05-01", 156], + ["2024-05-02", 167], + ["2024-05-03", 145], + ["2024-05-04", 123], + ["2024-05-05", 112], + ["2024-05-06", 134], + ["2024-05-07", 145], + ["2024-05-08", 156], + ["2024-05-09", 134], + ["2024-05-10", 123], + ["2024-05-11", 98], + ["2024-05-12", 89], + ["2024-05-13", 145], + ["2024-05-14", 156], + ["2024-05-15", 167], + ["2024-05-16", 145], + ["2024-05-17", 134], + ["2024-05-18", 112], + ["2024-05-19", 98], + ["2024-05-20", 134], + ["2024-05-21", 145], + ["2024-05-22", 156], + ["2024-05-23", 134], + ["2024-05-24", 123], + ["2024-05-25", 98], + ["2024-05-26", 89], + ["2024-05-27", 123], + ["2024-05-28", 134], + ["2024-05-29", 145], + ["2024-05-30", 123], + ["2024-05-31", 112], + ["2024-06-01", 98], + ["2024-06-02", 89], + ["2024-06-03", 134], + ["2024-06-04", 145], + ["2024-06-05", 156], + ["2024-06-06", 134], + ["2024-06-07", 123], + ["2024-06-08", 98], + ["2024-06-09", 87], + ["2024-06-10", 145], + ["2024-06-11", 156], + ["2024-06-12", 134], + ["2024-06-13", 123], + ["2024-06-14", 112], + ["2024-06-15", 98], + ["2024-06-16", 89], + ["2024-06-17", 134], + ["2024-06-18", 145], + ["2024-06-19", 123], + ["2024-06-20", 112], + ["2024-06-21", 98], + ["2024-06-22", 87], + ["2024-06-23", 76], + ["2024-06-24", 123], + ["2024-06-25", 134], + ["2024-06-26", 145], + ["2024-06-27", 123], + ["2024-06-28", 112], + ["2024-06-29", 98], + ["2024-06-30", 89], + ["2024-07-01", 145], + ["2024-07-02", 156], + ["2024-07-03", 167], + ["2024-07-04", 189], + ["2024-07-05", 145], + ["2024-07-06", 123], + ["2024-07-07", 98], + ["2024-07-08", 156], + ["2024-07-09", 167], + ["2024-07-10", 145], + ["2024-07-11", 134], + ["2024-07-12", 123], + ["2024-07-13", 98], + ["2024-07-14", 89], + ["2024-07-15", 156], + ["2024-07-16", 167], + ["2024-07-17", 178], + ["2024-07-18", 156], + ["2024-07-19", 134], + ["2024-07-20", 112], + ["2024-07-21", 98], + ["2024-07-22", 145], + ["2024-07-23", 156], + ["2024-07-24", 134], + ["2024-07-25", 123], + ["2024-07-26", 112], + ["2024-07-27", 98], + ["2024-07-28", 89], + ["2024-07-29", 134], + ["2024-07-30", 145], + ["2024-07-31", 123], + ["2024-08-01", 156], + ["2024-08-02", 167], + ["2024-08-03", 178], + ["2024-08-04", 156], + ["2024-08-05", 145], + ["2024-08-06", 156], + ["2024-08-07", 167], + ["2024-08-08", 178], + ["2024-08-09", 156], + ["2024-08-10", 134], + ["2024-08-11", 112], + ["2024-08-12", 98], + ["2024-08-13", 156], + ["2024-08-14", 167], + ["2024-08-15", 178], + ["2024-08-16", 156], + ["2024-08-17", 134], + ["2024-08-18", 112], + ["2024-08-19", 98], + ["2024-08-20", 145], + ["2024-08-21", 156], + ["2024-08-22", 134], + ["2024-08-23", 123], + ["2024-08-24", 112], + ["2024-08-25", 98], + ["2024-08-26", 89], + ["2024-08-27", 134], + ["2024-08-28", 145], + ["2024-08-29", 123], + ["2024-08-30", 112], + ["2024-08-31", 98], + ["2024-09-01", 89], + ["2024-09-02", 87], + ["2024-09-03", 145], + ["2024-09-04", 156], + ["2024-09-05", 167], + ["2024-09-06", 145], + ["2024-09-07", 123], + ["2024-09-08", 98], + ["2024-09-09", 89], + ["2024-09-10", 156], + ["2024-09-11", 167], + ["2024-09-12", 145], + ["2024-09-13", 134], + ["2024-09-14", 112], + ["2024-09-15", 98], + ["2024-09-16", 89], + ["2024-09-17", 145], + ["2024-09-18", 156], + ["2024-09-19", 134], + ["2024-09-20", 123], + ["2024-09-21", 98], + ["2024-09-22", 87], + ["2024-09-23", 76], + ["2024-09-24", 134], + ["2024-09-25", 145], + ["2024-09-26", 123], + ["2024-09-27", 112], + ["2024-09-28", 98], + ["2024-09-29", 89], + ["2024-09-30", 87], + ["2024-10-01", 167], + ["2024-10-02", 178], + ["2024-10-03", 189], + ["2024-10-04", 167], + ["2024-10-05", 145], + ["2024-10-06", 123], + ["2024-10-07", 98], + ["2024-10-08", 167], + ["2024-10-09", 178], + ["2024-10-10", 156], + ["2024-10-11", 145], + ["2024-10-12", 123], + ["2024-10-13", 112], + ["2024-10-14", 98], + ["2024-10-15", 167], + ["2024-10-16", 178], + ["2024-10-17", 189], + ["2024-10-18", 167], + ["2024-10-19", 134], + ["2024-10-20", 112], + ["2024-10-21", 98], + ["2024-10-22", 156], + ["2024-10-23", 167], + ["2024-10-24", 145], + ["2024-10-25", 134], + ["2024-10-26", 112], + ["2024-10-27", 98], + ["2024-10-28", 89], + ["2024-10-29", 145], + ["2024-10-30", 156], + ["2024-10-31", 134], + ["2024-11-01", 178], + ["2024-11-02", 189], + ["2024-11-03", 198], + ["2024-11-04", 178], + ["2024-11-05", 167], + ["2024-11-06", 178], + ["2024-11-07", 189], + ["2024-11-08", 198], + ["2024-11-09", 167], + ["2024-11-10", 145], + ["2024-11-11", 123], + ["2024-11-12", 112], + ["2024-11-13", 178], + ["2024-11-14", 189], + ["2024-11-15", 198], + ["2024-11-16", 167], + ["2024-11-17", 145], + ["2024-11-18", 123], + ["2024-11-19", 112], + ["2024-11-20", 167], + ["2024-11-21", 178], + ["2024-11-22", 156], + ["2024-11-23", 134], + ["2024-11-24", 112], + ["2024-11-25", 98], + ["2024-11-26", 89], + ["2024-11-27", 145], + ["2024-11-28", 156], + ["2024-11-29", 134], + ["2024-11-30", 123], + ["2024-12-01", 112], + ["2024-12-02", 123], + ["2024-12-03", 167], + ["2024-12-04", 178], + ["2024-12-05", 189], + ["2024-12-06", 167], + ["2024-12-07", 134], + ["2024-12-08", 112], + ["2024-12-09", 98], + ["2024-12-10", 167], + ["2024-12-11", 178], + ["2024-12-12", 156], + ["2024-12-13", 145], + ["2024-12-14", 123], + ["2024-12-15", 98], + ["2024-12-16", 89], + ["2024-12-17", 156], + ["2024-12-18", 167], + ["2024-12-19", 145], + ["2024-12-20", 134], + ["2024-12-21", 112], + ["2024-12-22", 98], + ["2024-12-23", 87], + ["2024-12-24", 76], + ["2024-12-25", 67], + ["2024-12-26", 89], + ["2024-12-27", 145], + ["2024-12-28", 156], + ["2024-12-29", 134], + ["2024-12-30", 189], + ["2024-12-31", 132], + ["2025-01-01", 45], + ["2025-01-02", 112], + ["2025-01-03", 156], + ["2025-01-04", 134], + ["2025-01-05", 98], + ["2025-01-06", 167], + ["2025-01-07", 135], + ["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], + ["2025-01-22", 142] + ], + "post": { + "ID": 12345, + "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.

", + "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/" + } +} 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-authors.json b/Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-authors.json new file mode 100644 index 000000000000..7558af154644 --- /dev/null +++ b/Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-authors.json @@ -0,0 +1,137 @@ +[ + { + "name": "Alex Johnson", + "userId": "1", + "role": "Editor-in-Chief", + "metrics": { + "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", + "userId": "2", + "role": "Editor-at-Large", + "metrics": { + "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", + "userId": "3", + "role": "Executive Editor", + "metrics": { + "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", + "userId": "4", + "role": "Senior Writer", + "metrics": { + "views": 130, + "comments": 45, + "likes": 95 + }, + "posts": [] + }, + { + "name": "Morgan Smith", + "userId": "5", + "role": "Senior Editor", + "metrics": { + "views": 105, + "comments": 38, + "likes": 82 + }, + "posts": [] + }, + { + "name": "Casey Brown", + "userId": "6", + "role": "Managing Editor", + "metrics": { + "views": 85, + "comments": 30, + "likes": 65 + }, + "posts": [] + }, + { + "name": "Riley Martinez", + "userId": "7", + "role": "Senior Reporter", + "metrics": { + "views": 70, + "comments": 25, + "likes": 55 + }, + "posts": [] + }, + { + "name": "Jamie Lee", + "userId": "8", + "role": "News Writer", + "metrics": { + "views": 60, + "comments": 20, + "likes": 45 + }, + "posts": [] + } +] \ 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..d21fc3eae007 --- /dev/null +++ b/Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-external-links.json @@ -0,0 +1,85 @@ +[ + { + "url": "https://developer.apple.com", + "title": "Apple Developer", + "children": [ + { + "url": "https://developer.apple.com/documentation/swiftui", + "title": "SwiftUI Documentation", + "children": [], + "metrics": { + "views": 50, + "visitors": 35 + } + }, + { + "url": "https://developer.apple.com/documentation/uikit", + "title": "UIKit Documentation", + "children": [], + "metrics": { + "views": 30, + "visitors": 20 + } + } + ], + "metrics": { + "views": 180, + "visitors": 120 + } + }, + { + "url": "https://swift.org", + "title": "Swift.org", + "children": [], + "metrics": { + "views": 150, + "visitors": 100 + } + }, + { + "url": "https://github.com", + "title": "GitHub", + "children": [ + { + "url": "https://github.com/wordpress-mobile/WordPress-iOS", + "title": "WordPress-iOS Repository", + "children": [], + "metrics": { + "views": 40, + "visitors": 25 + } + } + ], + "metrics": { + "views": 120, + "visitors": 80 + } + }, + { + "url": "https://stackoverflow.com", + "title": "Stack Overflow", + "children": [], + "metrics": { + "views": 100, + "visitors": 70 + } + }, + { + "url": "https://raywenderlich.com", + "title": "Ray Wenderlich", + "children": [], + "metrics": { + "views": 80, + "visitors": 50 + } + }, + { + "url": "https://nshipster.com", + "title": "NSHipster", + "children": [], + "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-locations.json b/Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-locations.json new file mode 100644 index 000000000000..fa3f08a6b647 --- /dev/null +++ b/Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-locations.json @@ -0,0 +1,66 @@ +[ + { + "country": "United States", + "countryCode": "US", + "flag": "🇺🇸", + "metrics": { + "views": 350 + } + }, + { + "country": "United Kingdom", + "countryCode": "GB", + "flag": "🇬🇧", + "metrics": { + "views": 150 + } + }, + { + "country": "Canada", + "countryCode": "CA", + "flag": "🇨🇦", + "metrics": { + "views": 120 + } + }, + { + "country": "Germany", + "countryCode": "DE", + "flag": "🇩🇪", + "metrics": { + "views": 95 + } + }, + { + "country": "Australia", + "countryCode": "AU", + "flag": "🇦🇺", + "metrics": { + "views": 80 + } + }, + { + "country": "France", + "countryCode": "FR", + "flag": "🇫🇷", + "metrics": { + "views": 65 + } + }, + { + "country": "Japan", + "countryCode": "JP", + "flag": "🇯🇵", + "metrics": { + "views": 55 + } + }, + { + "country": "Netherlands", + "countryCode": "NL", + "flag": "🇳🇱", + "metrics": { + "views": 45 + } + } +] \ No newline at end of file diff --git a/Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-postsAndPages.json b/Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-postsAndPages.json new file mode 100644 index 000000000000..010ab6ec8d7a --- /dev/null +++ b/Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-postsAndPages.json @@ -0,0 +1,200 @@ +[ + { + "postId": "rt1", + "title": "Breaking: Apple Vision Pro Review", + "author": "Nilay Patel", + "metrics": { + "views": 380, + "comments": 45, + "likes": 120 + } + }, + { + "postId": "rt2", + "title": "Live: Tesla Cybertruck First Drive", + "author": "David Pierce", + "metrics": { + "views": 320, + "comments": 38, + "likes": 95 + } + }, + { + "postId": "rt3", + "title": "Google Pixel 8 Pro vs iPhone 15 Pro", + "author": "Dieter Bohn", + "metrics": { + "views": 280, + "comments": 32, + "likes": 85 + } + }, + { + "postId": "rt4", + "title": "The Best Laptops of 2024", + "author": "Monica Chin", + "metrics": { + "views": 240, + "comments": 28, + "likes": 72 + } + }, + { + "postId": "rt5", + "title": "Microsoft Surface Laptop Studio 2 Review", + "author": "Tom Warren", + "metrics": { + "views": 210, + "comments": 25, + "likes": 65 + } + }, + { + "postId": "rt6", + "title": "Samsung Galaxy S24 Ultra: Hands-On", + "author": "Alex Cranz", + "metrics": { + "views": 180, + "comments": 22, + "likes": 58 + } + }, + { + "postId": "rt7", + "title": "Steam Deck OLED Review", + "author": "Adi Robertson", + "metrics": { + "views": 150, + "comments": 18, + "likes": 48 + } + }, + { + "postId": "rt8", + "title": "Meta Quest 3 vs PSVR 2", + "author": "Adi Robertson", + "metrics": { + "views": 130, + "comments": 15, + "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", + "author": "Jay Peters", + "metrics": { + "views": 110, + "comments": 12, + "likes": 35 + } + }, + { + "postId": "rt10", + "title": "Framework Laptop 16: The Ultimate Modular PC", + "author": "Monica Chin", + "metrics": { + "views": 95, + "comments": 10, + "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", + "author": "David Pierce", + "metrics": { + "views": 85, + "comments": 8, + "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", + "author": "Emma Roth", + "metrics": { + "views": 75, + "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/Resources/Mocks/RealtimeData/realtime-referrers.json b/Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-referrers.json new file mode 100644 index 000000000000..0f0d0218df1b --- /dev/null +++ b/Modules/Sources/JetpackStats/Resources/Mocks/RealtimeData/realtime-referrers.json @@ -0,0 +1,134 @@ +[ + { + "name": "Google Search", + "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 + } + }, + { + "name": "Twitter/X", + "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 + } + }, + { + "name": "Reddit", + "domain": "reddit.com", + "iconURL": "https://www.redditstatic.com/desktop2x/img/favicon/favicon-32x32.png", + "isSpam": false, + "children": [], + "metrics": { + "views": 130 + } + }, + { + "name": "Hacker News", + "domain": "news.ycombinator.com", + "iconURL": "https://news.ycombinator.com/favicon.ico", + "isSpam": false, + "children": [], + "metrics": { + "views": 100 + } + }, + { + "name": "Direct Traffic", + "domain": "direct", + "iconURL": null, + "isSpam": false, + "children": [], + "metrics": { + "views": 85 + } + }, + { + "name": "LinkedIn", + "domain": "linkedin.com", + "iconURL": "https://static.licdn.com/sc/h/al2o9zrvru7aqj8e1x2rzsrca", + "isSpam": false, + "children": [], + "metrics": { + "views": 70 + } + }, + { + "name": "Facebook", + "domain": "facebook.com", + "iconURL": "https://static.xx.fbcdn.net/rsrc.php/yD/r/d4ZIVX-5C-b.ico", + "isSpam": false, + "children": [], + "metrics": { + "views": 60 + } + }, + { + "name": "YouTube", + "domain": "youtube.com", + "iconURL": "https://www.youtube.com/s/desktop/8a0c81e8/img/favicon_32x32.png", + "isSpam": false, + "children": [], + "metrics": { + "views": 50 + } + } +] \ 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..ecc993a84a54 --- /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 + } + } +] 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..e9f53d62c553 --- /dev/null +++ b/Modules/Sources/JetpackStats/Resources/interactive-map-template.html @@ -0,0 +1,76 @@ + + + + + + + + + + + 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/Screens/ArchiveStatsView.swift b/Modules/Sources/JetpackStats/Screens/ArchiveStatsView.swift new file mode 100644 index 000000000000..5d5ede233d7c --- /dev/null +++ b/Modules/Sources/JetpackStats/Screens/ArchiveStatsView.swift @@ -0,0 +1,127 @@ +import SwiftUI +import DesignSystem + +struct ArchiveStatsView: View { + let archiveSection: TopListItem.ArchiveSection + let dateRange: StatsDateRange + + @Environment(\.context) private var context + @Environment(\.router) private var router + @Environment(\.horizontalSizeClass) var horizontalSizeClass + + var body: some View { + ScrollView { + VStack(spacing: Constants.step3) { + headerCard + if !archiveSection.items.isEmpty { + itemsCard + } + } + .padding(.vertical, Constants.step1) + .padding(.horizontal, Constants.cardHorizontalInset(for: horizontalSizeClass)) + .dynamicTypeSize(...DynamicTypeSize.xxxLarge) + } + .background(Constants.Colors.background) + .onAppear { + context.tracker?.send(.archiveStatsScreenShown) + } + .navigationTitle(archiveSection.displayName) + .navigationBarTitleDisplayMode(.inline) + } + + var headerCard: some View { + VStack(spacing: Constants.step2) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(archiveSection.displayName) + .font(.headline) + .foregroundColor(.primary) + + Text(Strings.ArchiveSections.itemCount(archiveSection.items.count)) + .font(.footnote) + .foregroundColor(.secondary) + } + + Spacer() + + if let totalViews = archiveSection.metrics.views { + StandaloneMetricView(metric: .views, value: totalViews) + } + } + } + .padding(Constants.step2) + .cardStyle() + } + + var itemsCard: some View { + VStack(alignment: .leading, spacing: Constants.step2) { + Text(itemsTitle) + .font(.headline) + .foregroundColor(.primary) + .padding(.horizontal, Constants.step3) + + TopListItemsView( + data: itemsChartData, + itemLimit: archiveSection.items.count, + dateRange: dateRange + ) + } + .padding(.vertical, Constants.step2) + .cardStyle() + } + + private var itemsTitle: String { + switch archiveSection.sectionName.lowercased() { + case "author": + return Strings.ArchiveSections.author + case "other": + return Strings.ArchiveSections.other + default: + return archiveSection.displayName + } + } + + private var itemsChartData: TopListData { + return TopListData( + item: .archive, + metric: .views, + items: archiveSection.items + ) + } +} + +// MARK: - Preview + +#Preview { + NavigationView { + ArchiveStatsView( + archiveSection: .mock, + dateRange: Calendar.demo.makeDateRange(for: .thisMonth) + ) + } + .tint(Constants.Colors.jetpack) +} + +private extension TopListItem.ArchiveSection { + static let mock = TopListItem.ArchiveSection( + sectionName: "author", + items: [ + TopListItem.ArchiveItem( + href: "/author/john-doe/", + value: "John Doe", + metrics: SiteMetricsSet(views: 5000) + ), + TopListItem.ArchiveItem( + href: "/author/jane-smith/", + value: "Jane Smith", + metrics: SiteMetricsSet(views: 4200) + ), + TopListItem.ArchiveItem( + href: "/author/mike-jones/", + value: "Mike Jones", + metrics: SiteMetricsSet(views: 3100) + ) + ], + metrics: SiteMetricsSet(views: 12300) + ) +} diff --git a/Modules/Sources/JetpackStats/Screens/AuthorStatsView.swift b/Modules/Sources/JetpackStats/Screens/AuthorStatsView.swift new file mode 100644 index 000000000000..7e92342f1464 --- /dev/null +++ b/Modules/Sources/JetpackStats/Screens/AuthorStatsView.swift @@ -0,0 +1,217 @@ +import SwiftUI +import DesignSystem +@preconcurrency import WordPressKit + +struct AuthorStatsView: View { + let author: TopListItem.Author + + @State private var dateRange: StatsDateRange + + @StateObject private var viewModel: TopListViewModel + + @Environment(\.context) private var context + @Environment(\.horizontalSizeClass) var horizontalSizeClass + + @ScaledMetric private var avatarSize = 60 + + init(author: TopListItem.Author, initialDateRange: StatsDateRange? = nil, context: StatsContext) { + self.author = author + + let range = initialDateRange ?? context.calendar.makeDateRange(for: .last30Days) + self._dateRange = State(initialValue: range) + + let configuration = TopListCardConfiguration( + item: .postsAndPages, + metric: .views + ) + self._viewModel = StateObject(wrappedValue: TopListViewModel( + configuration: configuration, + dateRange: range, + service: context.service, + tracker: context.tracker, + items: [.postsAndPages], + filter: .author(userId: author.userId) + )) + } + + var body: some View { + ScrollView { + VStack(spacing: Constants.step3) { + headerView + .cardStyle() + + TopListCard( + viewModel: viewModel, + itemLimit: 6, + reserveSpace: false, + showMoreInline: true + ) + } + .padding(.vertical, Constants.step1) + .padding(.horizontal, Constants.cardHorizontalInset(for: horizontalSizeClass)) + .frame(maxWidth: horizontalSizeClass == .regular ? Constants.maxHortizontalWidth : .infinity) + .frame(maxWidth: .infinity) + .dynamicTypeSize(...DynamicTypeSize.xxxLarge) + } + .background(Constants.Colors.background) + .animation(.spring, value: viewModel.data.map(ObjectIdentifier.init)) + .onChange(of: dateRange) { newRange in + viewModel.dateRange = newRange + } + .onAppear { + context.tracker?.send(.authorStatsScreenShown) + } + .navigationTitle(Strings.AuthorDetails.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if horizontalSizeClass == .regular { + ToolbarItemGroup(placement: .navigationBarTrailing) { + StatsDateRangeButtons(dateRange: $dateRange) + } + } + } + .safeAreaInset(edge: .bottom) { + if horizontalSizeClass == .compact { + LegacyFloatingDateControl(dateRange: $dateRange) + } + } + } + + private var headerView: some View { + VStack(spacing: Constants.step3) { + HStack(spacing: Constants.step3) { + // Avatar + AvatarView( + name: author.name, + imageURL: author.avatarURL, + size: avatarSize + ) + .overlay( + Circle() + .stroke(Color(.opaqueSeparator), lineWidth: 1) + ) + + // Name and metrics + VStack(alignment: .leading, spacing: Constants.step1) { + Text(author.name) + .font(.title3) + .fontWeight(.semibold) + .foregroundColor(.primary) + + // Views for period + if let data = calculatePeriodViews() { + makeViewsView(current: data.current, previous: data.previous) + } else { + makeViewsView(current: 1000, previous: 500) + .redacted(reason: .placeholder) + } + } + + Spacer() + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(Constants.step3) + } + + private func makeViewsView(current: Int, previous: Int?) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 4) { + Image(systemName: SiteMetric.views.systemImage) + .font(.caption.weight(.medium)) + .foregroundColor(.secondary) + + Text(SiteMetric.views.localizedTitle) + .font(.caption.weight(.medium)) + .foregroundColor(.secondary) + .textCase(.uppercase) + } + + HStack(spacing: Constants.step2) { + Text(StatsValueFormatter.formatNumber(current, onlyLarge: true)) + .font(Font.make(.recoleta, textStyle: .title2, weight: .medium)) + .foregroundColor(.primary) + .contentTransition(.numericText()) + + // Trend badge + if let previous { + let trend = TrendViewModel( + currentValue: current, + previousValue: previous, + metric: .views + ) + + HStack(spacing: 4) { + Image(systemName: trend.systemImage) + .font(.caption2.weight(.semibold)) + Text(trend.formattedPercentage) + .font(.caption.weight(.medium)) + .contentTransition(.numericText()) + } + .foregroundColor(trend.sentiment.foregroundColor) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(trend.sentiment.backgroundColor) + .clipShape(Capsule()) + } + } + } + } + + private func calculatePeriodViews() -> (current: Int, previous: Int?)? { + guard let data = viewModel.data else { return nil } + + // Sum up views from all posts in the current period + let currentViews = data.items.compactMap { item in + (item as? TopListItem.Post)?.metrics.views + }.reduce(0, +) + + // Calculate previous period views if available + var previousViews: Int? + if !data.previousItems.isEmpty { + previousViews = data.previousItems.values.compactMap { item in + (item as? TopListItem.Post)?.metrics.views + }.reduce(0, +) + } + + return (current: currentViews, previous: previousViews) + } +} + +#Preview { + NavigationStack { + AuthorStatsView( + author: TopListItem.Author( + name: "Alex Johnson", + userId: "1", + role: nil, + metrics: SiteMetricsSet( + views: 5000 + ), + avatarURL: nil, + posts: [ + TopListItem.Post( + title: "The Future of Technology: AI and Machine Learning", + postID: "1", + postURL: URL(string: "https://example.com/post1"), + date: Date(), + type: "post", + author: "Alex Johnson", + metrics: SiteMetricsSet(views: 1250) + ), + TopListItem.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) + ) + ] + ), + context: StatsContext.demo + ) + } + .environment(\.context, StatsContext.demo) +} diff --git a/Modules/Sources/JetpackStats/Screens/ChartDataListView.swift b/Modules/Sources/JetpackStats/Screens/ChartDataListView.swift new file mode 100644 index 000000000000..e75ffe2635b6 --- /dev/null +++ b/Modules/Sources/JetpackStats/Screens/ChartDataListView.swift @@ -0,0 +1,261 @@ +import SwiftUI +import UniformTypeIdentifiers + +struct ChartDataListView: View { + let data: ChartData + let dateRange: StatsDateRange + + @Environment(\.context) var context + @Environment(\.dismiss) private var dismiss + @Environment(\.horizontalSizeClass) var horizontalSizeClass + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: Constants.step4) { + summaryCard(for: data, metric: data.metric) + .padding(.vertical, Constants.step2) + .padding(.horizontal, Constants.step3) + .background(Constants.Colors.background.opacity(0.66)) + .cardStyle() + .padding(.top, Constants.step2) + .dynamicTypeSize(...DynamicTypeSize.xLarge) + + dataItemsView(for: data, metric: data.metric) + .padding(.horizontal, Constants.step1) + } + .padding(.horizontal, Constants.cardHorizontalInset(for: horizontalSizeClass)) + .frame(maxWidth: horizontalSizeClass == .regular ? Constants.maxHortizontalWidth : .infinity) + .frame(maxWidth: .infinity) + .dynamicTypeSize(...DynamicTypeSize.xxxLarge) + } + .background(Constants.Colors.secondaryBackground) + .navigationTitle(Strings.ChartData.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button(Strings.Buttons.done) { + dismiss() + } + } + ToolbarItem(placement: .topBarTrailing) { + Menu { + ShareLink( + item: ChartDataCSVRepresentation( + data: data, + dateRange: dateRange, + context: context + ), + preview: SharePreview( + generateCSVFilename(), + image: Image(systemName: "doc.text") + ) + ) { + Label(Strings.Buttons.downloadCSV, systemImage: "square.and.arrow.down") + } + } label: { + Image(systemName: "square.and.arrow.up") + } + .accessibilityLabel(Strings.Buttons.share) + } + } + } + + private func summaryCard(for chartData: ChartData, metric: SiteMetric) -> 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) { + Text(metric.localizedTitle) + .font(.title3.weight(.medium)) + Text(context.formatters.dateRange.string(from: dateRange.dateInterval)) + .font(.subheadline) + .foregroundColor(.secondary) + } + + // Metrics section + HStack(alignment: .top, spacing: 0) { + metricColumn( + label: Strings.ChartData.total, + value: formatter.format(value: chartData.currentTotal, context: .compact), + formatter: formatter + ) + .padding(.trailing, Constants.step2) + + metricColumn( + label: Strings.ChartData.previous, + value: formatter.format(value: chartData.previousTotal, context: .compact), + formatter: formatter + ) + .foregroundColor(.secondary) + + Spacer(minLength: 0) + + VStack(alignment: .trailing, spacing: 2) { + Text(Strings.ChartData.change.uppercased()) + .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(.title3.weight(.medium)) + .padding(.trailing, 8) + + Image(systemName: trendViewModel.systemImage) + .font(.footnote.weight(.medium)) + .padding(.bottom, 1) + + Text(trendViewModel.formattedPercentage) + .font(.title3.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(.title3.weight(.medium)) + } + } + + private func generateCSVFilename() -> String { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd" + let dateString = dateFormatter.string(from: Date()) + + let metricName = data.metric.localizedTitle + .replacingOccurrences(of: " ", with: "_") + + let dateRangeString = context.formatters.dateRange.string(from: dateRange.dateInterval) + .replacingOccurrences(of: "/", with: "-") + .replacingOccurrences(of: ",", with: "") + .replacingOccurrences(of: " ", with: "_") + + return "\(metricName)_\(dateRangeString)_\(dateString).csv" + } + + private func dataItemsView(for chartData: ChartData, metric: SiteMetric) -> some View { + let formatter = StatsValueFormatter(metric: metric) + return VStack(alignment: .leading, spacing: Constants.step1) { + VStack(alignment: .leading, spacing: Constants.step2) { + Text(Strings.ChartData.detailedData) + .font(.subheadline.weight(.semibold)) + + // Header + HStack { + Text(Strings.ChartData.date) + .font(.caption) + .fontWeight(.medium) + .foregroundColor(.secondary) + .tracking(0.5) + + Spacer() + + Text(Strings.ChartData.value) + .font(.caption) + .fontWeight(.medium) + .foregroundColor(.secondary) + .tracking(0.5) + } + } + .padding(.horizontal) + + VStack(spacing: Constants.step1 / 2) { + ForEach(chartData.currentData.reversed()) { point in + DataItemRow( + date: context.formatters.date.formatDate(point.date, granularity: chartData.granularity, context: .regular), + value: point.value, + maxValue: chartData.maxValue, + formatter: formatter, + metric: metric + ) + } + } + } + } +} + +// MARK: - Data Item Row + +private struct DataItemRow: View { + let date: String + let value: Int + let maxValue: Int + let formatter: StatsValueFormatter + let metric: SiteMetric + + var body: some View { + HStack(spacing: 16) { + Text(date) + .font(.callout) + .foregroundColor(.primary) + .lineLimit(1) + + Spacer(minLength: 8) + + Text(formatter.format(value: value)) + .font(.callout.weight(.medium)) + .foregroundColor(.primary) + .contentTransition(.numericText()) + } + .padding(.vertical, 12) + .padding(.horizontal, 12) + .background( + TopListItemBarBackground(value: value, maxValue: maxValue, barColor: metric.primaryColor) + ) + } +} + +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( + data: 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), + 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) + ] + ), + dateRange: Calendar.demo.makeDateRange(for: .last7Days) + ) + } +} diff --git a/Modules/Sources/JetpackStats/Screens/ExternalLinkStatsView.swift b/Modules/Sources/JetpackStats/Screens/ExternalLinkStatsView.swift new file mode 100644 index 000000000000..0c7b8051d41a --- /dev/null +++ b/Modules/Sources/JetpackStats/Screens/ExternalLinkStatsView.swift @@ -0,0 +1,181 @@ +import SwiftUI +import WordPressUI +import DesignSystem + +struct ExternalLinkStatsView: View { + let externalLink: TopListItem.ExternalLink + let dateRange: StatsDateRange + + private let imageSize: CGFloat = 28 + + @Environment(\.context) private var context + @Environment(\.router) private var router + @Environment(\.horizontalSizeClass) var horizontalSizeClass + + var body: some View { + ScrollView { + VStack(spacing: Constants.step3) { + headerCard + .dynamicTypeSize(...DynamicTypeSize.xLarge) + if !externalLink.children.isEmpty { + childrenCard + } + } + .padding(.vertical, Constants.step1) + .padding(.horizontal, Constants.cardHorizontalInset(for: horizontalSizeClass)) + .frame(maxWidth: horizontalSizeClass == .regular ? Constants.maxHortizontalWidth : .infinity) + .frame(maxWidth: .infinity) + .dynamicTypeSize(...DynamicTypeSize.xxxLarge) + } + .background(Constants.Colors.background) + .onAppear { + context.tracker?.send(.externalLinkStatsScreenShown) + } + .navigationTitle(Strings.ExternalLinkDetails.title) + .navigationBarTitleDisplayMode(.inline) + } + + private var placeholderIcon: some View { + Image(systemName: "link.circle.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(.secondary.opacity(0.5)) + } + + var headerCard: some View { + VStack(spacing: Constants.step2) { + externalLinkInfoRow + if let url = URL(string: externalLink.url) { + Divider() + openLinkButton(url: url) + } + } + .padding(Constants.step2) + .cardStyle() + } + + var externalLinkInfoRow: some View { + HStack(spacing: Constants.step1) { + linkIcon + linkDetails + Spacer() + viewsCount + } + } + + @ViewBuilder + var linkIcon: some View { + if let url = URL(string: externalLink.url), + let host = url.host, + let iconURL = URL(string: "https://www.google.com/s2/favicons?domain=\(host)&sz=128") { + CachedAsyncImage(url: iconURL) { image in + image + .resizable() + .aspectRatio(contentMode: .fit) + } placeholder: { + placeholderIcon + } + .frame(width: imageSize, height: imageSize) + } else { + placeholderIcon + .frame(width: imageSize, height: imageSize) + } + } + + var linkDetails: some View { + VStack(alignment: .leading, spacing: 2) { + Text(externalLink.title ?? externalLink.url) + .font(.headline) + .foregroundColor(.primary) + .lineLimit(2) + + if let url = URL(string: externalLink.url), let host = url.host { + Text(host) + .font(.subheadline) + .foregroundColor(.secondary) + } + } + } + + @ViewBuilder + var viewsCount: some View { + if let views = externalLink.metrics.views { + StandaloneMetricView(metric: .views, value: views) + } + } + + func openLinkButton(url: URL) -> some View { + Link(destination: url) { + Label(Strings.ExternalLinkDetails.openLink, systemImage: "arrow.up.right.square") + .foregroundColor(Constants.Colors.blue) + .frame(maxWidth: .infinity) + } + .buttonStyle(.plain) + } + + var childrenCard: some View { + VStack(alignment: .leading, spacing: Constants.step2) { + Text(Strings.ExternalLinkDetails.childLinks) + .font(.headline) + .foregroundColor(.primary) + .padding(.horizontal, Constants.step3) + + TopListItemsView( + data: childrenChartData, + itemLimit: externalLink.children.count, + dateRange: dateRange + ) + } + .padding(.vertical, Constants.step2) + .cardStyle() + } + + private var childrenChartData: TopListData { + return TopListData( + item: .externalLinks, + metric: .views, + items: externalLink.children + ) + } +} + +// MARK: - Preview + +#Preview { + NavigationView { + ExternalLinkStatsView( + externalLink: .mock, + dateRange: Calendar.demo.makeDateRange(for: .thisYear) + ) + } + .navigationViewStyle(.stack) + .tint(Constants.Colors.jetpack) +} + +private extension TopListItem.ExternalLink { + static let mock = TopListItem.ExternalLink( + url: "https://developer.apple.com", + title: "Apple Developer", + children: [ + TopListItem.ExternalLink( + url: "https://developer.apple.com/documentation/swiftui", + title: "SwiftUI Documentation", + children: [], + metrics: SiteMetricsSet(views: 850) + ), + TopListItem.ExternalLink( + url: "https://developer.apple.com/documentation/uikit", + title: "UIKit Documentation", + children: [], + metrics: SiteMetricsSet(views: 750) + ), + TopListItem.ExternalLink( + url: "https://developer.apple.com/xcode", + title: "Xcode", + children: [], + metrics: SiteMetricsSet(views: 600) + ) + ], + metrics: SiteMetricsSet(views: 2200) + ) +} 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/PostStatsView.swift b/Modules/Sources/JetpackStats/Screens/PostStatsView.swift new file mode 100644 index 000000000000..a90d0a37dbd5 --- /dev/null +++ b/Modules/Sources/JetpackStats/Screens/PostStatsView.swift @@ -0,0 +1,634 @@ +import SwiftUI +import UIKit +@preconcurrency import WordPressKit + +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: TopListItem.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? + @State private var emailData: StatsEmailOpensData? + @State private var isLoadingDetails = true + @State private var isLoadingLikes = true + @State private var isLoadingEmailData = true + @State private var error: Error? + + @AppStorage("JetpackStatsPostDetailsChartType") private var chartType: ChartType = .columns + + @Environment(\.context) private var context + @Environment(\.router) private var router + @Environment(\.horizontalSizeClass) var horizontalSizeClass + + init(post: TopListItem.Post, dateRange: StatsDateRange) { + self.post = PostInfo(from: post) + self.initialDateRange = dateRange + } + + init(post: PostInfo, dateRange: StatsDateRange) { + self.post = post + self.initialDateRange = dateRange + } + + 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.step3) { + contents + } + .padding(.vertical, Constants.step1) + .padding(.horizontal, Constants.cardHorizontalInset(for: horizontalSizeClass)) + .frame(maxWidth: horizontalSizeClass == .regular ? Constants.maxHortizontalWidth : .infinity) + .frame(maxWidth: .infinity) + } + .background(Constants.Colors.background) + .navigationTitle(Strings.PostDetails.title) + .navigationBarTitleDisplayMode(.inline) + .onAppear { + context.tracker?.send(.postDetailsScreenShown) + } + .task { + await loadPostDetails() + } + } + + @ViewBuilder + private var contents: some View { + headerView + .cardStyle() + .dynamicTypeSize(...DynamicTypeSize.xxLarge) + .accessibilityElement(children: .contain) + + if let data { + makeChartView(dataPoints: data.dataPoints) + } else if isLoadingDetails { + makeChartView(dataPoints: mockDataPoints) + .redacted(reason: .placeholder) + } + + emailsMetricsView + .dynamicTypeSize(...DynamicTypeSize.xxLarge) + + if horizontalSizeClass == .regular { + HStack(alignment: .top, spacing: Constants.step3) { + weeklyTrendsCard + .frame(maxWidth: .infinity) + yearlyTrendsCard + .frame(maxWidth: .infinity) + } + } else { + weeklyTrendsCard + yearlyTrendsCard + } + } + + @ViewBuilder + private var weeklyTrendsCard: some View { + if let data { + VStack(alignment: .leading, spacing: Constants.step2) { + StatsCardTitleView(title: Strings.PostDetails.recentWeeks) + WeeklyTrendsView(viewModel: data.weeklyTrends) + } + .accessibilityElement(children: .contain) + .accessibilityLabel(Strings.Accessibility.cardTitle(Strings.PostDetails.recentWeeks)) + .padding(Constants.step2) + .cardStyle() + } + } + + @ViewBuilder + private var yearlyTrendsCard: some View { + if let data { + VStack(alignment: .leading, spacing: Constants.step2) { + StatsCardTitleView(title: Strings.PostDetails.monthlyActivity) + YearlyTrendsView(viewModel: data.yearlyTrends) + } + .padding(Constants.step2) + .cardStyle() + } + } + + private func makeChartView(dataPoints: [DataPoint]) -> some View { + StandaloneChartCard( + dataPoints: dataPoints, + metric: .views, + initialDateRange: dateRange, + chartType: $chartType, + configuration: .init(minimumGranularity: .day) + ) + .cardStyle() + } + + private var headerView: some View { + VStack(alignment: .leading, spacing: Constants.step2) { + postDetailsView + + if let likes { + Button { + navigateToLikesList() + } label: { + PostLikesStripView(likes: likes) + .contentShape(Rectangle()) + } + } else if isLoadingLikes { + PostLikesStripView(likes: .mock) + .redacted(reason: .placeholder) + } + + Divider() + + if let error { + SimpleErrorView(error: error) + .frame(minHeight: 210) + } else { + PostStatsMetricsStripView( + metrics: metrics ?? .mock, + onLikesTapped: navigateToLikesList, + onCommentsTapped: navigateToCommentsList + ) + // Preserving view identity for better animations + .redacted(reason: metrics == nil ? .placeholder : []) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(EdgeInsets(top: Constants.step2, leading: Constants.step2, bottom: Constants.step1, trailing: Constants.step2)) + } + + @ViewBuilder + private var emailsMetricsView: some View { + // Email Metrics Card + if let emailData { + VStack(alignment: .leading, spacing: Constants.step2) { + StatsCardTitleView(title: Strings.PostDetails.emailMetrics) + PostStatsEmailMetricsView(emailData: emailData) + } + .padding(Constants.cardPadding) + .cardStyle() + } else if isLoadingEmailData { + VStack(alignment: .leading, spacing: Constants.step2) { + StatsCardTitleView(title: Strings.PostDetails.emailMetrics) + PostStatsEmailMetricsView(emailData: StatsEmailOpensData( + totalSends: 1000, + uniqueOpens: 500, + totalOpens: 750, + opensRate: 0.5 + )) + } + .padding(Constants.cardPadding) + .cardStyle() + .redacted(reason: .placeholder) + } + } + + 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 ?? data?.post?.dateGMT { + HStack(spacing: 6) { + Text(Strings.PostDetails.published(formatPublishedDate(dateGMT))) + .font(.subheadline) + .foregroundColor(.secondary) + + // Permalink button + if let postURL = post.postURL ?? data?.post?.permalink.flatMap(URL.init) { + Link(destination: postURL) { + Image(systemName: "link") + .font(.footnote) + .foregroundColor(Constants.Colors.blue) + } + } + } + } + } + } + + // MARK: - Data + + private var dateRange: StatsDateRange { + initialDateRange ?? context.calendar.makeDateRange(for: .last30Days) + } + + private var metrics: SiteMetricsSet? { + guard let data else { + return nil + } + return SiteMetricsSet( + views: data.views, + likes: likes?.totalCount, + comments: data.comments + ) + } + + 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 func loadPostDetails() async { + guard let postID = Int(post.postID) else { + self.error = URLError(.unknown, userInfo: [NSLocalizedDescriptionKey: Strings.Errors.generic]) + self.isLoadingDetails = false + return + } + + // 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 + } + + // Load email data in parallel and ignore errors + Task { + do { + self.emailData = try await context.service.getEmailOpens(for: postID) + } catch { + // Do nothing + } + self.isLoadingEmailData = false + } + + do { + let details = try await context.service.getPostDetails(for: postID) + let data = await makeData(with: details, calendar: context.calendar) + withAnimation(.spring) { + self.data = data + self.isLoadingDetails = false + } + } catch { + withAnimation(.spring) { + self.error = error + self.isLoadingDetails = false + } + } + } + + private var mockDataPoints: [DataPoint] { + ChartData.mock( + metric: .views, + granularity: dateRange.dateInterval.preferredGranularity, + range: dateRange + ).currentData + } + + private func navigateToLikesList() { + guard let postID = Int(post.postID) else { + return + } + router.navigateToLikesList( + siteID: context.siteID, + postID: postID, + totalLikes: likes?.totalCount ?? 0 + ) + } + + private func navigateToCommentsList() { + guard let postID = Int(post.postID) else { + return + } + router.navigateToCommentsList(siteID: context.siteID, postID: postID) + } +} + +private struct PostDetailsData: @unchecked Sendable { + 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)? + let onCommentsTapped: (() -> Void)? + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: Constants.step2) { + ForEach([SiteMetric.views, .likes, .comments]) { metric in + MetricView(metric: metric, value: metrics[metric]) + .contentShape(Rectangle()) + .onTapGesture { + switch metric { + case .likes: + onLikesTapped?() + case .comments: + onCommentsTapped?() + default: + break + } + } + } + } + } + } + + struct MetricView: View { + let metric: SiteMetric + let value: Int? + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 2) { + Image(systemName: metric.systemImage) + .font(.caption2.weight(.medium)) + .foregroundColor(.secondary) + + Text(metric.localizedTitle.uppercased()) + .font(.caption.weight(.medium)) + .foregroundColor(.secondary) + + 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, 1) + } + } + + 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) + } + + var formattedValue: String { + guard let value else { + return "–" + } + return StatsValueFormatter(metric: metric).format(value: value) + } + } +} + +private struct PostStatsEmailMetricsView: View { + let emailData: StatsEmailOpensData + + @Environment(\.horizontalSizeClass) var horizontalSizeClass + + var body: some View { + if horizontalSizeClass == .regular { + HStack(spacing: Constants.step4) { + ForEach(emailMetrics) { metric in + MetricView(metric: metric) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } else { + VStack(alignment: .leading, spacing: Constants.step2) { + HStack(spacing: Constants.step2) { + ForEach(emailMetrics.prefix(2)) { metric in + MetricView(metric: metric) + } + } + HStack(spacing: Constants.step2) { + ForEach(emailMetrics.suffix(2)) { metric in + MetricView(metric: metric) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + + private var emailMetrics: [EmailMetric] { + [ + EmailMetric( + id: "sends", + title: Strings.PostDetails.emailsSent.uppercased(), + value: emailData.totalSends ?? 0, + icon: "envelope" + ), + EmailMetric( + id: "rate", + title: Strings.PostDetails.openRate.uppercased(), + value: nil, + rate: emailData.opensRate, + icon: "percent" + ), + EmailMetric( + id: "unique", + title: Strings.PostDetails.uniqueOpens.uppercased(), + value: emailData.uniqueOpens ?? 0, + icon: "envelope.open" + ), + EmailMetric( + id: "total", + title: Strings.PostDetails.totalOpens.uppercased(), + value: emailData.totalOpens ?? 0, + icon: "envelope.open.fill" + ) + ] + } + + struct EmailMetric: Identifiable { + let id: String + let title: String + let value: Int? + var rate: Double? + let icon: String + } + + struct MetricView: View { + let metric: EmailMetric + + @ScaledMetric private var prererredWidth = 128 + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .center, spacing: 2) { + Image(systemName: metric.icon) + .font(.caption2.weight(.medium)) + .foregroundColor(.secondary) + + Text(metric.title) + .font(.caption.weight(.medium)) + .foregroundColor(.secondary) + } + HStack { + Text(formattedValue) + .contentTransition(.numericText()) + .font(Font.make(.recoleta, textStyle: .title, weight: .medium)) + .foregroundColor(.primary) + } + } + .lineLimit(1) + .frame(minWidth: prererredWidth, alignment: .leading) + } + + var formattedValue: String { + if let rate = metric.rate { + return "\(Int(rate * 100))%" + } else if let value = metric.value { + return value.formatted(.number.notation(.compactName)) + } else { + return "–" + } + } + } +} + +private struct PostLikesStripView: View { + let likes: PostLikesData + + private let avatarSize: CGFloat = 28 + 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 { + 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 { + HStack(spacing: -8) { + ForEach(0...2, id: \.self) { _ in + Circle() + .frame(width: avatarSize, height: avatarSize) + .foregroundStyle(Color(.secondarySystemBackground)) + .overlay( + Circle() + .stroke(Color(UIColor.systemBackground), lineWidth: 1) + ) + } + } + Text(Strings.PostDetails.noLikesYet) + .font(.subheadline) + .foregroundColor(.secondary) + } + .lineLimit(1) + } +} + +#Preview { + NavigationStack { + PostStatsView( + post: .init( + title: "Matter Smart Home Protocol Still Doesn't Matter: A Year Later", + postID: "12345", + postURL: URL(string: "example.com"), + date: .now + ), + dateRange: Calendar.demo.makeDateRange(for: .last30Days) + ) + .environment(\.context, StatsContext.demo) + } +} diff --git a/Modules/Sources/JetpackStats/Screens/RealtimeTabView.swift b/Modules/Sources/JetpackStats/Screens/RealtimeTabView.swift new file mode 100644 index 000000000000..caa96df35a83 --- /dev/null +++ b/Modules/Sources/JetpackStats/Screens/RealtimeTabView.swift @@ -0,0 +1,62 @@ +import SwiftUI + +struct RealtimeTabView: View { + @Environment(\.context) var context + @Environment(\.horizontalSizeClass) var horizontalSizeClass + @ScaledMetric private var maxWidth = 720 + + 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(.horizontal, Constants.cardHorizontalInset(for: horizontalSizeClass)) + .padding(.vertical, Constants.step2) + .frame(maxWidth: maxWidth, alignment: .center) + .frame(maxWidth: maxWidth) + } + .background(Constants.Colors.background) + } + + 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() + } +} + +// MARK: - Preview + +#Preview { + RealtimeTabView() +} diff --git a/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift b/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift new file mode 100644 index 000000000000..eef611ab9100 --- /dev/null +++ b/Modules/Sources/JetpackStats/Screens/ReferrerStatsView.swift @@ -0,0 +1,229 @@ +import SwiftUI +import WordPressUI +import DesignSystem + +struct ReferrerStatsView: View { + let referrer: TopListItem.Referrer + let dateRange: StatsDateRange + + private let imageSize: CGFloat = 28 + + @Environment(\.context) private var context + @Environment(\.router) private var router + @Environment(\.horizontalSizeClass) var horizontalSizeClass + @State private var isMarkingAsSpam = false + @State private var showErrorAlert = false + @State private var errorMessage = "" + @State private var isMarkedAsSpam = false + + var body: some View { + ScrollView { + VStack(spacing: Constants.step3) { + headerCard + .dynamicTypeSize(...DynamicTypeSize.xLarge) + if !referrer.children.isEmpty { + childrenCard + } + } + .padding(.vertical, Constants.step1) + .padding(.horizontal, Constants.cardHorizontalInset(for: horizontalSizeClass)) + .frame(maxWidth: horizontalSizeClass == .regular ? Constants.maxHortizontalWidth : .infinity) + .frame(maxWidth: .infinity) + .dynamicTypeSize(...DynamicTypeSize.xxxLarge) + } + .background(Constants.Colors.background) + .onAppear { + context.tracker?.send(.referrerStatsScreenShown) + } + .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 { + Image(systemName: "link.circle.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(.secondary.opacity(0.5)) + } + + var headerCard: some View { + VStack(spacing: Constants.step2) { + 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: imageSize, height: imageSize) + } else { + placeholderIcon + .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) + .tint(Constants.Colors.blue) + } else if let domain = referrer.domain { + Text(domain) + .font(.subheadline) + .foregroundColor(.secondary) + } + } + } + + @ViewBuilder + var viewsCount: some View { + if let views = referrer.metrics.views { + StandaloneMetricView(metric: .views, value: views) + } + } + + @ViewBuilder + var markAsSpamButton: some View { + if isMarkedAsSpam { + HStack { + Image(systemName: "checkmark.shield.fill") + .font(.subheadline) + Text(Strings.ReferrerDetails.markedAsSpam) + .font(.subheadline.weight(.medium)) + } + .foregroundColor(.secondary) + .frame(maxWidth: .infinity) + } else if isMarkingAsSpam { + ProgressView() + .frame(maxWidth: .infinity) + } else { + Button(role: .destructive) { + Task { + await markAsSpam() + } + } label: { + Label(Strings.ReferrerDetails.markAsSpam, systemImage: "exclamationmark.triangle") + .foregroundColor(.red) + .frame(maxWidth: .infinity) + } + .buttonStyle(.plain) + } + } + + var childrenCard: some View { + VStack(alignment: .leading, spacing: Constants.step2) { + Text(Strings.ReferrerDetails.referralSources) + .font(.headline) + .foregroundColor(.primary) + .padding(.horizontal, Constants.step3) + + TopListItemsView( + data: childrenChartData, + itemLimit: referrer.children.count, + dateRange: dateRange + ) + } + .padding(.vertical, Constants.step2) + .cardStyle() + } + + private var childrenChartData: TopListData { + return TopListData( + item: .referrers, + metric: .views, + items: referrer.children + ) + } + + private func markAsSpam() async { + guard let domain = referrer.domain else { return } + + isMarkingAsSpam = true + + do { + try await context.service.toggleSpamState(for: domain, currentValue: isMarkedAsSpam) + // Update local state to reflect the change + isMarkedAsSpam = true + } catch { + errorMessage = error.localizedDescription.isEmpty ? Strings.ReferrerDetails.markAsSpamError : error.localizedDescription + showErrorAlert = true + } + + isMarkingAsSpam = false + } +} + +// MARK: - Preview + +#Preview { + NavigationView { + ReferrerStatsView( + referrer: .mock, + dateRange: Calendar.demo.makeDateRange(for: .thisYear) + ) + } + .navigationViewStyle(.stack) + .tint(Constants.Colors.jetpack) +} + +private extension TopListItem.Referrer { + static let mock = TopListItem.Referrer( + name: "Google Search", + domain: "google.com", + iconURL: URL(string: "https://www.google.com/favicon.ico"), + children: [ + TopListItem.Referrer( + name: "wordpress development tutorial", + domain: "google.com", + iconURL: URL(string: "https://www.google.com/favicon.ico"), + children: [], + metrics: SiteMetricsSet(views: 850) + ), + TopListItem.Referrer( + name: "swift programming blog", + domain: "google.com", + iconURL: URL(string: "https://www.google.com/favicon.ico"), + children: [], + metrics: SiteMetricsSet(views: 750) + ), + TopListItem.Referrer( + name: "ios app development best practices", + domain: "google.com", + iconURL: URL(string: "https://www.google.com/favicon.ico"), + children: [], + metrics: SiteMetricsSet(views: 600) + ) + ], + metrics: SiteMetricsSet(views: 2200) + ) +} diff --git a/Modules/Sources/JetpackStats/Screens/StatsMainView.swift b/Modules/Sources/JetpackStats/Screens/StatsMainView.swift new file mode 100644 index 000000000000..057e102e7bec --- /dev/null +++ b/Modules/Sources/JetpackStats/Screens/StatsMainView.swift @@ -0,0 +1,104 @@ +import SwiftUI + +public struct StatsMainView: View { + @StateObject private var viewModel: StatsViewModel + + @State private var selectedTab = StatsTab.traffic + @State private var isTabBarBackgroundShown = true + + private let context: StatsContext + private let router: StatsRouter + private let showTabs: Bool + + public init(context: StatsContext, router: StatsRouter, showTabs: Bool = true) { + self.context = context + self.router = router + self.showTabs = showTabs + + let viewModel = StatsViewModel(context: context) + self._viewModel = StateObject(wrappedValue: viewModel) + } + + public var body: some View { + if showTabs { + tabContent + .id(selectedTab) + .trackScrollOffset(isScrolling: $isTabBarBackgroundShown) + .toolbarBackground(.hidden, for: .navigationBar) + .safeAreaInset(edge: .top) { + StatsTabBar(selectedTab: $selectedTab, showBackground: isTabBarBackgroundShown) + } + .background(Constants.Colors.background) + .navigationTitle(Strings.stats) + .navigationBarTitleDisplayMode(.inline) + .environment(\.context, context) + .environment(\.router, router) + .onAppear { + context.tracker?.send(.statsMainScreenShown) + } + .onChange(of: selectedTab) { newValue in + trackTabChange(from: selectedTab, to: newValue) + } + } else { + // When tabs are hidden, show only traffic tab without the tab bar + TrafficTabView(viewModel: viewModel, topPadding: Constants.step1) + .background(Constants.Colors.background) + .environment(\.context, context) + .environment(\.router, router) + .onAppear { + context.tracker?.send(.statsMainScreenShown) + } + } + } + + @ViewBuilder + private var tabContent: some View { + switch selectedTab { + case .traffic: + TrafficTabView(viewModel: viewModel) + .onAppear { + context.tracker?.send(.trafficTabShown) + } + case .realtime: + RealtimeTabView() + .onAppear { + context.tracker?.send(.realtimeTabShown) + } + case .insights: + InsightsTabView() + case .subscribers: + SubscribersTabView() + .onAppear { + context.tracker?.send(.subscribersTabShown) + } + } + } + + private func trackTabChange(from oldTab: StatsTab, to newTab: StatsTab) { + context.tracker?.send(.statsTabSelected, properties: [ + "tab_name": newTab.analyticsName, + "previous_tab": oldTab.analyticsName + ]) + } +} + +#Preview { + PreviewStatsMainView() + .ignoresSafeArea() +} + +private struct PreviewStatsMainView: UIViewControllerRepresentable { + + func makeUIViewController(context: Context) -> UINavigationController { + let navigationController = UINavigationController() + let router = StatsRouter(viewController: navigationController, factory: MockStatsRouterScreenFactory()) + let view = StatsMainView(context: .demo, router: router) + let hostingController = UIHostingController(rootView: view) + navigationController.viewControllers = [hostingController] + return navigationController + } + + func updateUIViewController(_ uiViewController: UINavigationController, context: Context) { + // No update needed + } +} 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/TopListScreenView.swift b/Modules/Sources/JetpackStats/Screens/TopListScreenView.swift new file mode 100644 index 000000000000..1bc6424c102d --- /dev/null +++ b/Modules/Sources/JetpackStats/Screens/TopListScreenView.swift @@ -0,0 +1,246 @@ +import SwiftUI +import DesignSystem +import UniformTypeIdentifiers + +struct TopListScreenView: View { + @StateObject private var viewModel: TopListViewModel + + @Environment(\.router) var router + @Environment(\.context) var context + @Environment(\.horizontalSizeClass) var horizontalSizeClass + + init( + selection: TopListViewModel.Selection, + dateRange: StatsDateRange, + service: any StatsServiceProtocol, + context: StatsContext, + initialData: TopListData? = nil, + filter: TopListViewModel.Filter? = nil, + ) { + let configuration = TopListCardConfiguration( + item: selection.item, + metric: selection.metric + ) + self._viewModel = StateObject(wrappedValue: TopListViewModel( + configuration: configuration, + dateRange: dateRange, + service: service, + tracker: context.tracker, + fetchLimit: nil, // Get all items + filter: filter, + initialData: initialData + )) + } + + var body: some View { + ScrollView { + VStack(spacing: Constants.step4) { + headerView + .background(Color(.secondarySystemBackground).opacity(0.7)) + .cardStyle() + .dynamicTypeSize(...DynamicTypeSize.xLarge) + .accessibilityElement(children: .contain) + .padding(.horizontal, Constants.step1) + + VStack { + listHeaderView + .padding(.horizontal, Constants.step1) + .dynamicTypeSize(...DynamicTypeSize.xLarge) + listContentView + .grayscale(viewModel.isStale ? 1 : 0) + .animation(.smooth, value: viewModel.isStale) + } + .padding(.horizontal, Constants.cardHorizontalInset(for: horizontalSizeClass)) + } + .dynamicTypeSize(...DynamicTypeSize.xxxLarge) + .padding(.vertical, Constants.step2) + .frame(maxWidth: horizontalSizeClass == .regular ? Constants.maxHortizontalWidthPlainLists : .infinity) + .frame(maxWidth: .infinity) + .animation(.spring, value: viewModel.data.map(ObjectIdentifier.init)) + } + .background(Color(.systemBackground)) + .navigationTitle(viewModel.selection.item.localizedTitle) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItemGroup(placement: .navigationBarTrailing) { + if horizontalSizeClass == .regular { + StatsDateRangeButtons(dateRange: $viewModel.dateRange) + } + Menu { + if let data = viewModel.data, !data.items.isEmpty { + ShareLink( + item: CSVDataRepresentation( + items: data.items, + metric: viewModel.selection.metric, + fileName: generateCSVFilename() + ), + preview: SharePreview( + generateCSVFilename(), + image: Image(systemName: "doc.text") + ) + ) { + Label(Strings.Buttons.downloadCSV, systemImage: "square.and.arrow.down") + } + } else { + Button(action: {}) { + Label(Strings.Buttons.downloadCSV, systemImage: "square.and.arrow.down") + } + .disabled(true) + } + } label: { + Image(systemName: "ellipsis") + } + .accessibilityLabel(Strings.Accessibility.moreOptions) + } + } + .onAppear { + viewModel.onAppear() + } + .safeAreaInset(edge: .bottom) { + if horizontalSizeClass == .compact { + LegacyFloatingDateControl(dateRange: $viewModel.dateRange) + } + } + } + + @ViewBuilder + private var headerView: some View { + HStack(alignment: .center, spacing: Constants.step1) { + VStack(alignment: .leading, spacing: 4) { + Text(viewModel.selection.item.getTitle(for: viewModel.selection.metric)) + .font(.headline) + .foregroundColor(.primary) + Text(context.formatters.dateRange.string(from: viewModel.dateRange.dateInterval)) + .font(.subheadline) + .foregroundColor(.secondary) + } + + Spacer() + + // Always show the metrics view to preserve identity + metricsOverviewView(data: viewModel.data ?? mockData) + .redacted(reason: viewModel.isFirstLoad ? .placeholder : []) + .pulsating(viewModel.isFirstLoad) + .animation(.smooth, value: viewModel.isFirstLoad) + } + .padding(.vertical, Constants.step2) + .padding(.horizontal, Constants.step3) + } + + private var listHeaderView: some View { + HStack { + Text(viewModel.selection.item.localizedTitle) + .font(.subheadline) + .fontWeight(.medium) + + Spacer() + + Text(viewModel.selection.metric.localizedTitle) + .font(.subheadline) + .fontWeight(.medium) + } + } + + @ViewBuilder + private func metricsOverviewView(data: TopListData) -> some View { + let formattedValue = StatsValueFormatter(metric: data.metric) + .format(value: data.metrics.total) + let trend = TrendViewModel( + currentValue: data.metrics.total, + previousValue: data.metrics.previousTotal, + metric: data.metric + ) + + VStack(alignment: .trailing, spacing: 0) { + Text(formattedValue) + .contentTransition(.numericText()) + .font(Font.make(.recoleta, textStyle: .title, weight: .medium)) + .foregroundColor(.primary) + .lineLimit(1) + .animation(.spring, value: formattedValue) + + Text(trend.formattedTrendShort2) + .font(.system(.subheadline, design: .rounded, weight: .medium)).tracking(-0.2) + .foregroundStyle(trend.sentiment.foregroundColor) + .padding(.top, -4) + } + } + + @ViewBuilder + private var listContentView: some View { + if viewModel.isFirstLoad { + itemsListView(data: mockData) + .redacted(reason: .placeholder) + .pulsating() + } else if let data = viewModel.data { + if data.items.isEmpty { + makeEmptyStateView(message: Strings.Chart.empty) + } else { + itemsListView(data: data) + } + } else { + makeEmptyStateView(message: viewModel.loadingError?.localizedDescription ?? Strings.Errors.generic) + } + } + + private func itemsListView(data: TopListData) -> some View { + VStack(spacing: Constants.step0_5) { + ForEach(data.items, id: \.id) { item in + TopListItemView( + item: item, + previousValue: data.previousItem(for: item)?.metrics[viewModel.selection.metric], + metric: viewModel.selection.metric, + maxValue: data.metrics.maxValue, + dateRange: viewModel.dateRange + ) + .frame(height: TopListItemView.defaultCellHeight) + } + } + } + + private func makeEmptyStateView(message: String) -> some View { + itemsListView(data: mockData) + .redacted(reason: .placeholder) + .grayscale(1) + .opacity(0.25) + .overlay { + SimpleErrorView(message: message) + } + } + + private var mockData: TopListData { + TopListData.mock( + for: viewModel.selection.item, + metric: viewModel.selection.metric, + itemCount: 10 + ) + } + + // MARK: - CSV Export + + private func generateCSVFilename() -> String { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd" + let dateString = dateFormatter.string(from: Date()) + + let itemName = viewModel.selection.item.localizedTitle + .replacingOccurrences(of: " ", with: "_") + .replacingOccurrences(of: "&", with: "and") + + let metricName = viewModel.selection.metric.localizedTitle + .replacingOccurrences(of: " ", with: "_") + + return "\(itemName)_\(metricName)_\(dateString).csv" + } +} + +#Preview { + NavigationStack { + TopListScreenView( + selection: .init(item: .postsAndPages, metric: .views), + dateRange: Calendar.demo.makeDateRange(for: .last28Days), + service: MockStatsService(), + context: .demo + ) + } +} diff --git a/Modules/Sources/JetpackStats/Screens/TrafficTabView.swift b/Modules/Sources/JetpackStats/Screens/TrafficTabView.swift new file mode 100644 index 000000000000..d109c416b5e6 --- /dev/null +++ b/Modules/Sources/JetpackStats/Screens/TrafficTabView.swift @@ -0,0 +1,157 @@ +import SwiftUI + +struct TrafficTabView: View { + @ObservedObject var viewModel: StatsViewModel + + @State private var isShowingCustomRangePicker = false + @State private var isShowingAddCardSheet = false + + @Environment(\.context) var context + @Environment(\.horizontalSizeClass) var horizontalSizeClass + + // Temporary workaround while we are still showing this in the existing UIKit screens. + private let topPadding: CGFloat + + init(viewModel: StatsViewModel, topPadding: CGFloat = 0) { + self.viewModel = viewModel + self.topPadding = topPadding + } + + var body: some View { + ScrollViewReader { proxy in + ScrollView { + VStack(spacing: Constants.step3) { + cards + buttonAddChart + timeZoneInfo + } + .padding(.vertical, Constants.step2 + (horizontalSizeClass == .regular ? Constants.step1 : 0)) + .padding(.horizontal, Constants.cardHorizontalInset(for: horizontalSizeClass)) + .padding(.top, topPadding) + .onReceive(viewModel.scrollToCardSubject) { cardID in + // Use a more elegant spring animation for scrolling + withAnimation(.spring) { + proxy.scrollTo(cardID, anchor: .top) + } + } + } + .background(Constants.Colors.background) + .animation(.spring, value: viewModel.cards.map(\.id)) + .listStyle(.plain) + } + .toolbar { + if horizontalSizeClass == .regular { + ToolbarItemGroup(placement: .navigationBarTrailing) { + StatsDateRangeButtons(dateRange: $viewModel.dateRange) + } + } + } + .safeAreaInset(edge: .bottom) { + if horizontalSizeClass == .compact { + LegacyFloatingDateControl(dateRange: $viewModel.dateRange) + } + } + .sheet(isPresented: $isShowingCustomRangePicker) { + CustomDateRangePicker(dateRange: $viewModel.dateRange) + } + } + + @ViewBuilder + private var cards: some View { + if horizontalSizeClass == .regular { + var cards = viewModel.cards + if let first = cards.first as? ChartCardViewModel { + let _ = cards.removeFirst() + cardView(for: first) + } + HStack(alignment: .top, spacing: Constants.step2) { + VStack(spacing: Constants.step3) { + ForEach(Array(cards.enumerated()), id: \.element.id) { index, card in + if index % 2 == 0 { + cardView(for: card) + } + } + } + VStack(spacing: Constants.step3) { + ForEach(Array(cards.enumerated()), id: \.element.id) { index, card in + if index % 2 == 1 { + cardView(for: card) + } + } + } + } + } else { + ForEach(viewModel.cards, id: \.id) { card in + cardView(for: card) + } + } + } + + @ViewBuilder + private func makeItem(for viewModel: TrafficCardViewModel) -> some View { + switch viewModel { + case let viewModel as ChartCardViewModel: + ChartCard(viewModel: viewModel) + case let viewModel as TopListViewModel: + TopListCard(viewModel: viewModel) + default: + let _ = assertionFailure("Unsupported type: \(viewModel)") + EmptyView() + } + } + + @ViewBuilder + private func cardView(for card: TrafficCardViewModel) -> some View { + makeItem(for: card) + .id(card.id) + .transition(.asymmetric( + insertion: .push(from: .bottom).combined(with: .opacity), + removal: .scale.combined(with: .opacity) + )) + } + + // MARK: - Misc + + private var buttonAddChart: some View { + // Add Chart Button + Button(action: { + isShowingAddCardSheet = true + }) { + HStack(spacing: Constants.step1) { + Image(systemName: "plus") + .font(.headline) + Text(Strings.Buttons.addCard) + .font(.headline) + } + .foregroundColor(.secondary) + .padding(3) + } + .accessibilityLabel(Strings.Accessibility.addCardButton) + .dynamicTypeSize(...DynamicTypeSize.xLarge) + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + .popover(isPresented: $isShowingAddCardSheet) { + AddCardSheet { cardType in + viewModel.addCard(type: cardType) + } + .dynamicTypeSize(...DynamicTypeSize.xLarge) + .modifier(PopoverPresentationModifier()) + } + } + + private var timeZoneInfo: some View { + TimezoneInfoView() + .padding(.horizontal, Constants.step4) + .padding(.top, Constants.step2) + .padding(.bottom, Constants.step1) + .dynamicTypeSize(...DynamicTypeSize.xLarge) + } +} + +#Preview { + NavigationView { + TrafficTabView(viewModel: StatsViewModel(context: .demo)) + } + .environment(\.context, .demo) + .environment(\.router, StatsRouter(viewController: UINavigationController(), factory: MockStatsRouterScreenFactory())) +} diff --git a/Modules/Sources/JetpackStats/Services/CSVExporter.swift b/Modules/Sources/JetpackStats/Services/CSVExporter.swift new file mode 100644 index 000000000000..1ee61f0caf9a --- /dev/null +++ b/Modules/Sources/JetpackStats/Services/CSVExporter.swift @@ -0,0 +1,143 @@ +import Foundation +import SwiftUI + +protocol CSVExporterProtocol { + func generateCSV(from items: [any TopListItemProtocol], metric: SiteMetric) -> String +} + +/// Exports stats data to CSV format following RFC 4180 standard +struct CSVExporter: CSVExporterProtocol { + // RFC 4180: Use CRLF for line endings + static let lineEnding = "\r\n" + + // Characters that require field escaping according to RFC 4180 + static let charactersRequiringEscape = CharacterSet(charactersIn: ",\"\r\n") + + func generateCSV(from items: [any TopListItemProtocol], metric: SiteMetric) -> String { + guard !items.isEmpty else { return "" } + + // Get the type of the first item to access static headers + let itemType = type(of: items.first!) + guard let exportableType = itemType as? any CSVExportable.Type else { + return "" + } + + // Pre-allocate capacity for better performance + var csvLines = [String]() + csvLines.reserveCapacity(items.count + 1) + + // Build header row + let headers = exportableType.csvHeaders + [metric.localizedTitle] + csvLines.append(Self.buildCSVRow(from: headers)) + + // Build data rows + for item in items { + guard let exportableItem = item as? CSVExportable else { continue } + + let values = exportableItem.csvValues + [formatMetricValue(item.metrics[metric])] + csvLines.append(Self.buildCSVRow(from: values)) + } + + return csvLines.joined(separator: Self.lineEnding) + } + + /// Builds a CSV row from an array of values, properly escaping fields as needed + static func buildCSVRow(from values: [String]) -> String { + values + .map { escapeCSVField($0) } + .joined(separator: ",") + } + + /// Formats a metric value for CSV export + private func formatMetricValue(_ value: Int?) -> String { + "\(value ?? 0)" + } + + /// Escapes a CSV field according to RFC 4180 rules: + /// - Fields containing comma, quotes, CR, or LF must be enclosed in double quotes + /// - Double quotes within fields must be escaped by doubling them + static func escapeCSVField(_ field: String) -> String { + // Quick check if escaping is needed + guard field.rangeOfCharacter(from: charactersRequiringEscape) != nil else { + return field + } + + // Escape quotes by doubling them and wrap the field in quotes + let escaped = field.replacingOccurrences(of: "\"", with: "\"\"") + return "\"\(escaped)\"" + } +} + +struct CSVDataRepresentation: Transferable { + let items: [any TopListItemProtocol] + let metric: SiteMetric + let fileName: String + + static var transferRepresentation: some TransferRepresentation { + let dataRepresentation = DataRepresentation(exportedContentType: .commaSeparatedText) { (representation: CSVDataRepresentation) in + try representation.generateCSVData() + } + if #available(iOS 17.0, *) { + return dataRepresentation.suggestedFileName { $0.fileName } + } else { + return dataRepresentation + } + } + + private func generateCSVData() throws -> Data { + let exporter = CSVExporter() + let csvContent = exporter.generateCSV(from: items, metric: metric) + guard let data = csvContent.data(using: .utf8) else { + throw CocoaError(.fileWriteUnknown) + } + return data + } +} + +struct ChartDataCSVRepresentation: Transferable { + let data: ChartData + let dateRange: StatsDateRange + let context: StatsContext + + static var transferRepresentation: some TransferRepresentation { + let dataRepresentation = DataRepresentation(exportedContentType: .commaSeparatedText) { (representation: ChartDataCSVRepresentation) in + try representation.generateCSVData() + } + if #available(iOS 17.0, *) { + return dataRepresentation.suggestedFileName { representation in + let dateString = representation.context.formatters.dateRange.string(from: representation.dateRange.dateInterval) + .replacingOccurrences(of: "/", with: "-") + .replacingOccurrences(of: ",", with: "") + return "\(representation.data.metric.localizedTitle)-\(dateString).csv" + } + } else { + return dataRepresentation + } + } + + private func generateCSVData() throws -> Data { + let csvContent = generateCSV() + guard let data = csvContent.data(using: .utf8) else { + throw CocoaError(.fileWriteUnknown) + } + return data + } + + private func generateCSV() -> String { + let formatter = StatsValueFormatter(metric: data.metric) + var csvLines = [String]() + + // Header row + let headers = [Strings.CSVExport.date, data.metric.localizedTitle] + csvLines.append(CSVExporter.buildCSVRow(from: headers)) + + // Data rows + for point in data.currentData { + let dateString = context.formatters.date.formatDate(point.date, granularity: data.granularity) + let valueString = formatter.format(value: point.value) + csvLines.append(CSVExporter.buildCSVRow(from: [dateString, valueString])) + } + + return csvLines.joined(separator: CSVExporter.lineEnding) + } +} diff --git a/Modules/Sources/JetpackStats/Services/Data/CSVExportable.swift b/Modules/Sources/JetpackStats/Services/Data/CSVExportable.swift new file mode 100644 index 000000000000..9220a73c4134 --- /dev/null +++ b/Modules/Sources/JetpackStats/Services/Data/CSVExportable.swift @@ -0,0 +1,166 @@ +import Foundation + +protocol CSVExportable { + static var csvHeaders: [String] { get } + var csvValues: [String] { get } +} + +extension TopListItem.Post: CSVExportable { + static var csvHeaders: [String] { + [ + Strings.CSVExport.title, + Strings.CSVExport.url, + Strings.CSVExport.date, + Strings.CSVExport.type, + ] + } + + var csvValues: [String] { + [ + title, + postURL?.absoluteString ?? "", + date?.formatted(date: .abbreviated, time: .omitted) ?? "", + type ?? "" + ] + } +} + +extension TopListItem.Referrer: CSVExportable { + static var csvHeaders: [String] { + [ + Strings.CSVExport.name, + Strings.CSVExport.domain + ] + } + + var csvValues: [String] { + [ + name, + domain ?? "" + ] + } +} + +extension TopListItem.Location: CSVExportable { + static var csvHeaders: [String] { + [ + Strings.CSVExport.country, + Strings.CSVExport.countryCode + ] + } + + var csvValues: [String] { + [ + country, + countryCode ?? "" + ] + } +} + +extension TopListItem.Author: CSVExportable { + static var csvHeaders: [String] { + [ + Strings.CSVExport.name, + Strings.CSVExport.role + ] + } + + var csvValues: [String] { + [ + name, + role ?? "" + ] + } +} + +extension TopListItem.ExternalLink: CSVExportable { + static var csvHeaders: [String] { + [ + Strings.CSVExport.title, + Strings.CSVExport.url + ] + } + + var csvValues: [String] { + [ + title ?? url, + url + ] + } +} + +extension TopListItem.FileDownload: CSVExportable { + static var csvHeaders: [String] { + [ + Strings.CSVExport.fileName, + Strings.CSVExport.filePath + ] + } + + var csvValues: [String] { + [ + fileName, + filePath ?? "" + ] + } +} + +extension TopListItem.SearchTerm: CSVExportable { + static var csvHeaders: [String] { + [ + Strings.CSVExport.searchTerm + ] + } + + var csvValues: [String] { + [ + term + ] + } +} + +extension TopListItem.Video: CSVExportable { + static var csvHeaders: [String] { + [ + Strings.CSVExport.title, + Strings.CSVExport.videoURL + ] + } + + var csvValues: [String] { + [ + title, + videoURL?.absoluteString ?? "" + ] + } +} + +extension TopListItem.ArchiveItem: CSVExportable { + static var csvHeaders: [String] { + [ + Strings.CSVExport.name, + Strings.CSVExport.url + ] + } + + var csvValues: [String] { + [ + value, + href + ] + } +} + +extension TopListItem.ArchiveSection: CSVExportable { + static var csvHeaders: [String] { + [ + Strings.CSVExport.section + ] + } + + var csvValues: [String] { + [ + displayName + ] + } +} diff --git a/Modules/Sources/JetpackStats/Services/Data/DataPoint.swift b/Modules/Sources/JetpackStats/Services/Data/DataPoint.swift new file mode 100644 index 000000000000..c31961f2d36c --- /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 nil + } + let total = dataPoints.reduce(0) { $0 + $1.value } + switch metric.aggregationStrategy { + case .average: + return total / dataPoints.count + case .sum: + return total + } + } +} diff --git a/Modules/Sources/JetpackStats/Services/Data/PostLikeUser.swift b/Modules/Sources/JetpackStats/Services/Data/PostLikeUser.swift new file mode 100644 index 000000000000..b2e6935a1429 --- /dev/null +++ b/Modules/Sources/JetpackStats/Services/Data/PostLikeUser.swift @@ -0,0 +1,38 @@ +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"), + 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/SiteMetric.swift b/Modules/Sources/JetpackStats/Services/Data/SiteMetric.swift new file mode 100644 index 000000000000..88b19fc6a116 --- /dev/null +++ b/Modules/Sources/JetpackStats/Services/Data/SiteMetric.swift @@ -0,0 +1,84 @@ +import SwiftUI + +enum SiteMetric: String, CaseIterable, Identifiable, Sendable, Codable { + case views + case visitors + case likes + case comments + case posts + case timeOnSite + case bounceRate + case downloads + + 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 .posts: Strings.SiteMetrics.posts + case .timeOnSite: Strings.SiteMetrics.timeOnSite + case .bounceRate: Strings.SiteMetrics.bounceRate + case .downloads: Strings.SiteMetrics.downloads + } + } + + var systemImage: String { + switch self { + case .views: "eyeglasses" + case .visitors: "person" + case .likes: "star" + case .comments: "bubble.left" + case .posts: "text.page" + case .timeOnSite: "clock" + case .bounceRate: "rectangle.portrait.and.arrow.right" + case .downloads: "arrow.down.circle" + } + } + + var primaryColor: Color { + switch self { + case .views: Constants.Colors.blue + case .visitors: Constants.Colors.purple + case .likes: Constants.Colors.pink + case .comments: Constants.Colors.green + case .posts: Constants.Colors.celadon + case .timeOnSite: Constants.Colors.orange + case .bounceRate: Constants.Colors.pink + case .downloads: Constants.Colors.blue + } + } + + 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, .posts, .downloads: + return true + case .bounceRate: + return false + } + } + + var aggregationStrategy: AggregationStrategy { + switch self { + case .views, .visitors, .likes, .comments, .posts, .downloads: + 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/SiteMetricsResponse.swift b/Modules/Sources/JetpackStats/Services/Data/SiteMetricsResponse.swift new file mode 100644 index 000000000000..a052de0d793a --- /dev/null +++ b/Modules/Sources/JetpackStats/Services/Data/SiteMetricsResponse.swift @@ -0,0 +1,13 @@ +import Foundation + +struct SiteMetricsResponse: 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..0dad946ef515 --- /dev/null +++ b/Modules/Sources/JetpackStats/Services/Data/SiteMetricsSet.swift @@ -0,0 +1,54 @@ +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? + var downloads: 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 + case .downloads: downloads + } + } + 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 + case .downloads: downloads = newValue + } + } + } + + 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/TopListData.swift b/Modules/Sources/JetpackStats/Services/Data/TopListData.swift new file mode 100644 index 000000000000..34201dba507c --- /dev/null +++ b/Modules/Sources/JetpackStats/Services/Data/TopListData.swift @@ -0,0 +1,434 @@ +import Foundation + +final class TopListData { + let item: TopListItemType + let metric: SiteMetric + let items: [any TopListItemProtocol] + let previousItems: [TopListItemID: any TopListItemProtocol] + let metrics: Metrics + + struct Metrics { + let maxValue: Int + let total: Int + let previousTotal: Int + } + + struct ListID: Hashable { + let item: TopListItemType + let metric: SiteMetric + } + + var listID: ListID { + ListID(item: item, metric: metric) + } + + init(item: TopListItemType, metric: SiteMetric, items: [any TopListItemProtocol], previousItems: [TopListItemID: any TopListItemProtocol] = [:]) { + self.item = item + self.metric = metric + self.items = items + self.previousItems = previousItems + + // Precompute metrics + let maxValue = items.compactMap { $0.metrics[metric] }.max() ?? 0 + let total = items.reduce(0) { $0 + ($1.metrics[metric] ?? 0) } + let previousTotal = previousItems.values.reduce(0) { $0 + ($1.metrics[metric] ?? 0) } + + self.metrics = Metrics( + maxValue: maxValue, + total: total, + previousTotal: previousTotal + ) + } + + func previousItem(for currentItem: any TopListItemProtocol) -> (any TopListItemProtocol)? { + previousItems[currentItem.id] + } +} + +// MARK: - Mock Data + +extension TopListData { + private struct CacheKey: Hashable { + let itemType: TopListItemType + let metric: SiteMetric + let itemCount: Int + } + + @MainActor + private static var mockDataCache: [CacheKey: TopListData] = [:] + + @MainActor + static func mock( + for itemType: TopListItemType, + metric: SiteMetric = .views, + itemCount: Int = 6 + ) -> TopListData { + 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) + .prefix(itemCount) + + // Create previous items dictionary + var previousItemsDict: [TopListItemID: any TopListItemProtocol] = [:] + for item in currentItems { + let previousItem = mockPreviousItem(from: item, metric: metric) + previousItemsDict[item.id] = previousItem + } + + let chartData = TopListData( + item: itemType, + metric: metric, + items: Array(currentItems), + previousItems: previousItemsDict + ) + + // Cache the generated data + mockDataCache[cacheKey] = chartData + + return chartData + } + + private static func mockItems(for item: TopListItemType, metric: SiteMetric) -> [any TopListItemProtocol] { + switch item { + 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) -> [TopListItem.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.enumerated().map { index, data in + let baseValue = data.2 + let metrics = createMetrics(baseValue: baseValue, metric: metric) + return TopListItem.Post( + title: data.0, + postID: "\(index + 1)", + postURL: nil, + date: nil, + type: nil, + author: data.1, + metrics: metrics + ) + } + } + + private static func mockReferrers(metric: SiteMetric) -> [TopListItem.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.enumerated().map { index, data in + let baseValue = data.2 + let metrics = createMetrics(baseValue: baseValue, metric: metric) + return TopListItem.Referrer( + name: data.0, + domain: data.1, + iconURL: nil, + children: [ + TopListItem.Referrer( + name: "wordpress development tutorial", + domain: "google.com", + iconURL: URL(string: "https://www.google.com/favicon.ico"), + children: [], + metrics: SiteMetricsSet(views: 850) + ), + TopListItem.Referrer( + name: "swift programming blog", + domain: "google.com", + iconURL: URL(string: "https://www.google.com/favicon.ico"), + children: [], + metrics: SiteMetricsSet(views: 750) + ), + TopListItem.Referrer( + name: "ios app development best practices", + domain: "google.com", + iconURL: URL(string: "https://www.google.com/favicon.ico"), + children: [], + metrics: SiteMetricsSet(views: 600) + ) + ], + metrics: metrics + ) + } + } + + private static func mockLocations(metric: SiteMetric) -> [TopListItem.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.enumerated().map { index, data in + let baseValue = data.3 + let metrics = createMetrics(baseValue: baseValue, metric: metric) + return TopListItem.Location( + country: data.0, + flag: data.2, + countryCode: data.1, + metrics: metrics + ) + } + } + + private static func mockAuthors(metric: SiteMetric) -> [TopListItem.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.enumerated().map { index, data in + let baseValue = data.3 + let metrics = createMetrics(baseValue: baseValue, metric: metric) + return TopListItem.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) -> [TopListItem.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.enumerated().map { index, data in + let baseValue = data.2 + let metrics = createMetrics(baseValue: baseValue, metric: metric) + return TopListItem.ExternalLink( + url: data.1, + title: data.0, + children: [], + metrics: metrics + ) + } + } + + private static func mockFileDownloads(metric: SiteMetric) -> [TopListItem.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.enumerated().map { index, data in + let baseValue = data.2 + let metrics = createMetrics(baseValue: baseValue, metric: metric) + return TopListItem.FileDownload( + fileName: data.0, + filePath: data.1, + metrics: metrics + ) + } + } + + private static func mockSearchTerms(metric: SiteMetric) -> [TopListItem.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.enumerated().map { index, data in + let baseValue = data.1 + let metrics = createMetrics(baseValue: baseValue, metric: metric) + return TopListItem.SearchTerm( + term: data.0, + metrics: metrics + ) + } + } + + private static func mockVideos(metric: SiteMetric) -> [TopListItem.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.enumerated().map { index, data in + let baseValue = data.3 + let metrics = createMetrics(baseValue: baseValue, metric: metric) + return TopListItem.Video( + title: data.0, + postId: data.1, + videoURL: URL(string: data.2), + metrics: metrics + ) + } + } + + private static func mockArchive(metric: SiteMetric) -> [any TopListItemProtocol] { + // 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.map { sectionData in + let sectionName = sectionData.0 + let items = sectionData.1.map { itemData in + let metrics = createMetrics(baseValue: itemData.1, metric: metric) + return TopListItem.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 TopListItem.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 + let variation = Double.random(in: 0.8...1.2) + let value = Int(Double(baseValue) * variation) + + switch metric { + case .views: + return SiteMetricsSet(views: value) + case .visitors: + // Visitors are typically 60-80% of views + let visitorRatio = Double.random(in: 0.6...0.8) + 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 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 SiteMetricsSet(comments: Int(Double(value) * commentRatio)) + case .posts: + let postsRatio = Double.random(in: 0.002...0.005) + 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) + case .bounceRate: + // Bounce rate not applicable for top list items + return SiteMetricsSet(views: value) + } + } + + private static func mockPreviousItem(from item: any TopListItemProtocol, metric: SiteMetric) -> any TopListItemProtocol { + 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 + item.metrics[metric] = Int(Double(currentValue) * trendFactor) + + // Special handling for archive sections - update child items too + if var archiveSection = item as? TopListItem.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/TopListItem+WordPressKit.swift b/Modules/Sources/JetpackStats/Services/Data/TopListItem+WordPressKit.swift new file mode 100644 index 000000000000..bd6b1738e498 --- /dev/null +++ b/Modules/Sources/JetpackStats/Services/Data/TopListItem+WordPressKit.swift @@ -0,0 +1,137 @@ +import Foundation +@preconcurrency import WordPressKit + +extension TopListItem.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 TopListItem.Referrer { + init(_ referrer: WordPressKit.StatsReferrer) { + self.init( + name: referrer.title, + domain: referrer.url?.host, + iconURL: referrer.iconURL, + children: referrer.children.map { TopListItem.Referrer($0) }, + metrics: SiteMetricsSet(views: referrer.viewsCount) + ) + } +} + +extension TopListItem.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 TopListItem.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 { TopListItem.Post($0, dateFormatter: dateFormatter) } + ) + } +} + +extension TopListItem.ExternalLink { + init(_ click: WordPressKit.StatsClick) { + self.init( + url: click.clickedURL?.absoluteString ?? "", + title: click.title, + children: click.children.map { TopListItem.ExternalLink($0) }, + metrics: SiteMetricsSet(views: click.clicksCount) + ) + } +} + +extension TopListItem.FileDownload { + init(_ download: WordPressKit.StatsFileDownload) { + self.init( + fileName: URL(string: download.file)?.lastPathComponent ?? download.file, + filePath: download.file, + metrics: SiteMetricsSet(downloads: download.downloadCount) + ) + } +} + +extension TopListItem.SearchTerm { + init(_ searchTerm: WordPressKit.StatsSearchTerm) { + self.init( + term: searchTerm.term, + metrics: SiteMetricsSet(views: searchTerm.viewsCount) + ) + } +} + +extension TopListItem.Video { + init(_ video: WordPressKit.StatsVideo) { + self.init( + title: video.title, + postId: String(video.postID), + videoURL: video.videoURL, + metrics: SiteMetricsSet(views: video.playsCount) + ) + } +} + +extension TopListItem.ArchiveItem { + init(_ item: WordPressKit.StatsArchiveItem) { + self.init( + href: item.href, + value: item.value, + metrics: SiteMetricsSet(views: item.views) + ) + } +} + +extension TopListItem.ArchiveSection { + init(sectionName: String, items: [WordPressKit.StatsArchiveItem]) { + let archiveItems = items.map { TopListItem.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/Data/TopListItem.swift b/Modules/Sources/JetpackStats/Services/Data/TopListItem.swift new file mode 100644 index 000000000000..cffbdb4337b6 --- /dev/null +++ b/Modules/Sources/JetpackStats/Services/Data/TopListItem.swift @@ -0,0 +1,144 @@ +import Foundation + +struct TopListResponse: Sendable { + let items: [any TopListItemProtocol] +} + +struct TopListItem: Sendable { + let items: [any TopListItemProtocol] +} + +/// - 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 +} + +protocol TopListItemProtocol: Codable, Sendable, Identifiable { + var metrics: SiteMetricsSet { get set } + var id: TopListItemID { get } +} + +extension TopListItem { + struct Post: Codable, TopListItemProtocol { + let title: String + let postID: String? + var postURL: URL? + let date: Date? + let type: String? + let author: String? + var metrics: SiteMetricsSet + + var id: TopListItemID { + TopListItemID(type: .postsAndPages, id: postID ?? title) + } + } + + struct Referrer: Codable, TopListItemProtocol { + let name: String + let domain: String? + let iconURL: URL? + let children: [Referrer] + var metrics: SiteMetricsSet + + var id: TopListItemID { + TopListItemID(type: .referrers, id: (domain ?? "–") + name) + } + } + + struct Location: Codable, TopListItemProtocol { + let country: String + let flag: String? + let countryCode: String? + var metrics: SiteMetricsSet + + var id: TopListItemID { + TopListItemID(type: .locations, id: countryCode ?? country) + } + } + + struct Author: Codable, TopListItemProtocol { + let name: String + let userId: String + let role: String? + var metrics: SiteMetricsSet + var avatarURL: URL? + var posts: [Post]? + + var id: TopListItemID { + TopListItemID(type: .authors, id: userId) + } + } + + struct ExternalLink: Codable, TopListItemProtocol { + let url: String + let title: String? + let children: [ExternalLink] + var metrics: SiteMetricsSet + + var id: TopListItemID { + TopListItemID(type: .externalLinks, id: url) + } + } + + struct FileDownload: Codable, TopListItemProtocol { + let fileName: String + let filePath: String? + var metrics: SiteMetricsSet + + var id: TopListItemID { + TopListItemID(type: .fileDownloads, id: filePath ?? fileName) + } + } + + struct SearchTerm: Codable, TopListItemProtocol { + let term: String + var metrics: SiteMetricsSet + + var id: TopListItemID { + TopListItemID(type: .searchTerms, id: term) + } + } + + struct Video: Codable, TopListItemProtocol { + let title: String + let postId: String + let videoURL: URL? + var metrics: SiteMetricsSet + + var id: TopListItemID { + TopListItemID(type: .videos, id: postId) + } + } + + struct ArchiveItem: Codable, TopListItemProtocol { + let href: String + let value: String + var metrics: SiteMetricsSet + + var id: TopListItemID { + TopListItemID(type: .archive, id: href) + } + } + + struct ArchiveSection: Codable, TopListItemProtocol { + let sectionName: String + var items: [ArchiveItem] + var metrics: SiteMetricsSet + + var id: TopListItemID { + TopListItemID(type: .archive, id: sectionName) + } + + var displayName: String { + switch sectionName.lowercased() { + case "author": Strings.ArchiveSections.author + case "other": Strings.ArchiveSections.other + default: sectionName.capitalized + } + } + } +} diff --git a/Modules/Sources/JetpackStats/Services/Data/TopListItemType.swift b/Modules/Sources/JetpackStats/Services/Data/TopListItemType.swift new file mode 100644 index 000000000000..8ea79d0d56ce --- /dev/null +++ b/Modules/Sources/JetpackStats/Services/Data/TopListItemType.swift @@ -0,0 +1,85 @@ +import SwiftUI + +enum TopListItemType: String, Identifiable, CaseIterable, Sendable, Codable { + case postsAndPages + case authors + case referrers + case locations + case videos + case externalLinks + case searchTerms + case fileDownloads + case archive + + var id: TopListItemType { self } + + var localizedTitle: String { + switch self { + case .postsAndPages: Strings.SiteDataTypes.postsAndPages + case .authors: Strings.SiteDataTypes.authors + 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 + case .archive: Strings.SiteDataTypes.archive + } + } + + var systemImage: String { + switch self { + case .postsAndPages: "text.page" + case .referrers: "link" + case .locations: "map" + case .authors: "person" + case .externalLinks: "cursorarrow.click" + case .fileDownloads: "arrow.down.circle" + case .searchTerms: "magnifyingglass" + case .videos: "play.rectangle" + case .archive: "folder" + } + } + + 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 .posts: Strings.TopListTitles.mostPosts + case .bounceRate: Strings.TopListTitles.highestBounceRate + case .timeOnSite: Strings.TopListTitles.longestTimeOnSite + case .downloads: Strings.TopListTitles.mostDownloadeded + } + } + + static let secondaryItems: Set = [ + .externalLinks, .fileDownloads, .searchTerms, .archive + ] + + var documentationURL: URL? { + URL(string: documentationPath) + } + + private var documentationPath: String { + switch self { + case .postsAndPages, .archive: + "https://wordpress.com/support/stats/analyze-content-performance/#view-posts-pages-traffic" + case .authors: + "https://wordpress.com/support/stats/analyze-content-performance/#check-author-performance" + case .referrers: + "https://wordpress.com/support/stats/understand-traffic-sources/#identify-referrers" + case .searchTerms: + "https://wordpress.com/support/stats/understand-traffic-sources/#analyze-search-terms" + case .fileDownloads: + "https://wordpress.com/support/stats/analyze-content-performance/#track-file-downloads" + case .externalLinks: + "https://wordpress.com/support/stats/analyze-content-performance/#analyze-clicks" + case .locations: + "https://wordpress.com/support/stats/audience-insights/" + case .videos: + "https://wordpress.com/support/stats/analyze-content-performance/#see-video-traffic" + } + } +} diff --git a/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift b/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift new file mode 100644 index 000000000000..601cc11ca8f8 --- /dev/null +++ b/Modules/Sources/JetpackStats/Services/Mocks/MockStatsService.swift @@ -0,0 +1,610 @@ +import Foundation +import SwiftUI +@preconcurrency import WordPressKit + +actor MockStatsService: ObservableObject, StatsServiceProtocol { + private var hourlyData: [SiteMetric: [DataPoint]] = [:] + private var dailyTopListData: [TopListItemType: [Date: [any TopListItemProtocol]]] = [:] + private let calendar: Calendar + + let supportedMetrics = SiteMetric.allCases.filter { + $0 != .downloads && $0 != .bounceRate && $0 != .timeOnSite + } + let supportedItems = TopListItemType.allCases + + 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] + case .externalLinks: [.views, .visitors] + case .fileDownloads: [.downloads] + case .searchTerms: [.views, .visitors] + case .videos: [.views, .likes] + } + } + + /// - 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 -> SiteMetricsResponse { + await generateDataIfNeeded() + + var total = SiteMetricsSet() + var output: [SiteMetric: [DataPoint]] = [:] + + let aggregator = StatsDataAggregator(calendar: calendar) + + for (metric, allDataPoints) in hourlyData { + // Filter data points for the period + 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, + 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))) + + return SiteMetricsResponse(total: total, metrics: output) + } + + func getTopListData(_ item: TopListItemType, metric: SiteMetric, interval: DateInterval, granularity: DateRangeGranularity, limit: Int?) async throws -> TopListResponse { + await generateDataIfNeeded() + + guard let typeData = dailyTopListData[item] else { + fatalError("data not configured for data type: \(item)") + } + + // Filter data within the date range + let filteredData = typeData.filter { date, _ in + interval.start <= date && date < interval.end + } + + // Aggregate all items across the date range + var aggregatedItems: [TopListItemID: (any TopListItemProtocol, Int)] = [:] // Store item and aggregated metrics + + for (_, dailyItems) in filteredData { + for item in dailyItems { + let key = item.id + 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[metric] ?? 0) + } + } + } + + // Convert to array with updated metric value and sort + let sortedItems = aggregatedItems.values + .map { (item, totalValue) -> any TopListItemProtocol in + // Create a mutable copy and update the aggregated metric value + var mutableItem = item + mutableItem.metrics[metric] = totalValue + return mutableItem + } + .sorted { ($0.metrics[metric] ?? 0) > ($1.metrics[metric] ?? 0) } + + try? await Task.sleep(for: .milliseconds(Int.random(in: 200...500))) + + return TopListResponse(items: Array(sortedItems.prefix(limit ?? Int.max))) + } + + func getRealtimeTopListData(_ dataType: TopListItemType) async throws -> TopListResponse { + // Load base items from JSON + let baseItems = loadRealtimeBaseItems(for: dataType) + + // Add dynamic variations to simulate real-time changes + let realtimeItems = baseItems.map { item -> any TopListItemProtocol 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) + } + if let downloads = mutableItem.metrics.downloads { + mutableItem.metrics.downloads = Int(Double(downloads) * slowWave * smallVariation * rareSpike) + } + + 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 TopListResponse(items: topItems) + } + + private func loadRealtimeBaseItems(for dataType: TopListItemType) -> [any TopListItemProtocol] { + let fileName: String + switch dataType { + case .postsAndPages: + fileName = "postsAndPages" + case .archive: + fileName = "archive" + case .referrers: + fileName = "referrers" + case .locations: + fileName = "locations" + case .authors: + fileName = "authors" + case .externalLinks: + fileName = "external-links" + case .fileDownloads: + fileName = "file-downloads" + case .searchTerms: + fileName = "search-terms" + case .videos: + fileName = "videos" + } + + // Load from JSON file + guard let url = Bundle.module.url(forResource: "realtime-\(fileName)", withExtension: "json") else { + print("Failed to find \(fileName).json") + return [] + } + + do { + let data = try Data(contentsOf: url) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + // Decode based on data type + switch dataType { + case .referrers: + let referrers = try decoder.decode([TopListItem.Referrer].self, from: data) + return referrers + case .locations: + let locations = try decoder.decode([TopListItem.Location].self, from: data) + return locations + case .authors: + let authors = try decoder.decode([TopListItem.Author].self, from: data) + return authors.map { + var copy = $0 + copy.avatarURL = Bundle.module.path(forResource: "author\($0.userId)", ofType: "jpg").map { + URL(filePath: $0) + } + return copy + } + case .externalLinks: + let links = try decoder.decode([TopListItem.ExternalLink].self, from: data) + return links + case .fileDownloads: + let downloads = try decoder.decode([TopListItem.FileDownload].self, from: data) + return downloads + case .searchTerms: + let terms = try decoder.decode([TopListItem.SearchTerm].self, from: data) + return terms + case .videos: + let videos = try decoder.decode([TopListItem.Video].self, from: data) + return videos + case .postsAndPages: + let posts = try decoder.decode([TopListItem.Post].self, from: data) + return posts + case .archive: + let sections = try decoder.decode([TopListItem.ArchiveSection].self, from: data) + return sections + } + } catch { + print("Failed to load \(fileName).json: \(error)") + return [] + } + } + + 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 + } + + func getPostLikes(for postID: Int, count: Int) async throws -> PostLikesData { + // Simulate network delay + try? await Task.sleep(for: .milliseconds(Int.random(in: 200...500))) + + 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") + ] + + let requestedCount = min(count, mockUsers.count) + let selectedUsers = Array(mockUsers.prefix(requestedCount)) + + 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) + } + } + + func getEmailOpens(for postID: Int) async throws -> StatsEmailOpensData { + // Simulate network delay + try? await Task.sleep(for: .milliseconds(Int.random(in: 200...500))) + + // Generate realistic random data + let totalSends = Int.random(in: 500...5000) + let uniqueOpens = Int.random(in: 100...min(totalSends, 2000)) + let totalOpens = Int.random(in: uniqueOpens...min(totalSends * 2, uniqueOpens * 3)) + let opensRate = Double(uniqueOpens) / Double(totalSends) + + return StatsEmailOpensData( + totalSends: totalSends, + uniqueOpens: uniqueOpens, + totalOpens: totalOpens, + opensRate: opensRate + ) + } + + // MARK: - Data Loading + + /// Loads historical items from JSON files based on the data type + private func loadHistoricalItems(for dataType: TopListItemType) -> [any TopListItemProtocol] { + let fileName: String + switch dataType { + case .postsAndPages: + fileName = "historical-postsAndPages" + case .archive: + fileName = "historical-archive" + case .referrers: + fileName = "historical-referrers" + case .locations: + fileName = "historical-locations" + case .authors: + fileName = "historical-authors" + case .externalLinks: + 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 + 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() + decoder.dateDecodingStrategy = .iso8601 + + // Decode based on data type + switch dataType { + case .referrers: + let referrers = try decoder.decode([TopListItem.Referrer].self, from: data) + return referrers + case .locations: + let locations = try decoder.decode([TopListItem.Location].self, from: data) + return locations + case .authors: + let authors = try decoder.decode([TopListItem.Author].self, from: data) + return authors.map { + var copy = $0 + copy.avatarURL = Bundle.module.path(forResource: "author\($0.userId)", ofType: "jpg").map { + URL(filePath: $0) + } + return copy + } + case .externalLinks: + let links = try decoder.decode([TopListItem.ExternalLink].self, from: data) + return links + case .fileDownloads: + let downloads = try decoder.decode([TopListItem.FileDownload].self, from: data) + return downloads + case .searchTerms: + let terms = try decoder.decode([TopListItem.SearchTerm].self, from: data) + return terms + case .videos: + let videos = try decoder.decode([TopListItem.Video].self, from: data) + return videos + case .postsAndPages: + let posts = try decoder.decode([TopListItem.Post].self, from: data) + return posts + case .archive: + let sections = try decoder.decode([TopListItem.ArchiveSection].self, from: data) + return sections + } + } catch { + print("Failed to load \(fileName).json: \(error)") + return [] + } + } + + // MARK: - Data Generation + + /// Mutates item metrics based on growth factors and variations + private func mutateItemMetrics(_ item: any TopListItemProtocol, growthFactor: Double, seasonalFactor: Double, weekendFactor: Double, randomFactor: Double) -> any TopListItemProtocol { + 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) + } + if let downloads = item.metrics.downloads { + item.metrics.downloads = Int(Double(downloads) * combinedFactor) + } + 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 .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)) + + case .bounceRate: + // 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) + } + } + + 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 TopListItemProtocol]] = [:] + + // 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 + 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? TopListItem.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) + 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..d2dedbc982d1 --- /dev/null +++ b/Modules/Sources/JetpackStats/Services/Mocks/StatsDataAggregator.swift @@ -0,0 +1,127 @@ +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 with normalization for views (sum strategy) +/// let dailyViews = aggregator.aggregate(hourlyData, granularity: .day, metric: .views) +/// // Result: [ +/// // Date("2025-01-15T00:00:00Z"): 470, // sum of all views +/// // Date("2025-01-16T00:00:00Z"): 480 // sum of all views +/// // ] +/// +/// // 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 (average) +/// // Date("2025-01-16T00:00:00Z"): 240 // 480/2 (average) +/// // ] +/// ``` +struct StatsDataAggregator { + var calendar: Calendar + + /// 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] + aggregatedData[aggregatedDate] = AggregatedDataPoint( + sum: (existing?.sum ?? 0) + dataPoint.value, + count: (existing?.count ?? 0) + 1 + ) + } + } + + // Second pass: normalize based on metric strategy + var normalizedData: [Date: Int] = [:] + for (date, dataPoint) in aggregatedData { + switch metric.aggregationStrategy { + case .sum: + normalizedData[date] = dataPoint.sum + case .average: + if dataPoint.count > 0 { + normalizedData[date] = dataPoint.sum / dataPoint.count + } + } + } + + return normalizedData + } + + 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 + 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 + } + + /// 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 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) + + // 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/Sources/JetpackStats/Services/StatsService.swift b/Modules/Sources/JetpackStats/Services/StatsService.swift new file mode 100644 index 000000000000..54818635f926 --- /dev/null +++ b/Modules/Sources/JetpackStats/Services/StatsService.swift @@ -0,0 +1,543 @@ +import Foundation +import WordPressShared +@preconcurrency import WordPressKit + +/// - 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 service: StatsServiceRemoteV2 + private let siteTimeZone: TimeZone + // Temporary + private var mocks: MockStatsService + + // Cache + private var siteStatsCache: [SiteStatsCacheKey: CachedEntity] = [:] + private var topListCache: [TopListCacheKey: CachedEntity] = [:] + private let currentPeriodTTL: TimeInterval = 30 // 30 seconds for current period + + let supportedMetrics: [SiteMetric] = [ + .views, .visitors, .likes, .comments, .posts + ] + + let supportedItems: [TopListItemType] = [ + .postsAndPages, .authors, .referrers, .locations, + .externalLinks, .fileDownloads, .searchTerms, .videos, .archive + ] + + nonisolated func getSupportedMetrics(for item: TopListItemType) -> [SiteMetric] { + switch item { + case .postsAndPages: [.views] + case .archive: [.views] + case .referrers: [.views] + case .locations: [.views] + case .authors: [.views] + case .externalLinks: [.views] + case .fileDownloads: [.downloads] + case .searchTerms: [.views] + case .videos: [.views] + } + } + + init(siteID: Int, api: WordPressComRestApi, timeZone: TimeZone) { + self.siteID = siteID + self.api = api + self.service = StatsServiceRemoteV2( + wordPressComRestApi: api, + siteID: siteID, + siteTimezone: timeZone + ) + self.siteTimeZone = timeZone + self.mocks = MockStatsService(timeZone: timeZone) + } + + // MARK: - StatsServiceProtocol + + func getSiteStats(interval: DateInterval, granularity: DateRangeGranularity) async throws -> SiteMetricsResponse { + // 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] = CachedEntity(data: data, timestamp: Date(), ttl: ttl) + + return data + } + + private func fetchSiteStats(interval: DateInterval, granularity: DateRangeGranularity) async throws -> SiteMetricsResponse { + let interval = convertDateIntervalSiteToLocal(interval) + + if granularity == .hour { + // Hourly data is available only for "Views", so the service has to + // make a separate request to fetch the total metrics. + async let hourlyResponseTask: WordPressKit.StatsSiteMetricsResponse = service.getData(interval: interval, unit: .init(granularity), limit: 0) + async let dailyResponseTask: WordPressKit.StatsSiteMetricsResponse = service.getData(interval: interval, unit: .init(.day), limit: 0) + + let (hourlyResponse, dailyResponse) = try await (hourlyResponseTask, dailyResponseTask) + + var data = mapSiteMetricsResponse(hourlyResponse) + data.total = mapSiteMetricsResponse(dailyResponse).total + return data + } else { + let response: WordPressKit.StatsSiteMetricsResponse = try await service.getData(interval: interval, unit: .init(granularity), limit: 0) + return mapSiteMetricsResponse(response) + } + } + + func getTopListData(_ item: TopListItemType, metric: SiteMetric, interval: DateInterval, granularity: DateRangeGranularity, limit: Int?) async throws -> TopListResponse { + // Check cache first + let cacheKey = TopListCacheKey(item: item, metric: metric, interval: interval, granularity: granularity, limit: limit) + if let cached = topListCache[cacheKey], !cached.isExpired { + return cached.data + } + + // Fetch fresh data + do { + let data = try await _getTopListData(item, metric: metric, interval: interval, granularity: granularity, limit: limit) + + // Cache the result + // Historical data never expires (ttl = nil), current period data expires after 30 seconds + let ttl = intervalContainsCurrentDate(interval) ? currentPeriodTTL : nil + topListCache[cacheKey] = CachedEntity(data: data, timestamp: Date(), ttl: ttl) + + return data + } 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 TopListResponse(items: []) + } + throw error + } + } + + private func _getTopListData(_ item: TopListItemType, metric: SiteMetric, interval: DateInterval, granularity: DateRangeGranularity, limit: Int?) async throws -> TopListResponse { + + 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, limit: limit ?? 0, parameters: parameters) + } + + // Helper function to sort items by metric value (descending) and then by itemID for stable ordering + func sortItems(_ items: [any TopListItemProtocol]) -> [any TopListItemProtocol] { + items.sorted { lhs, rhs in + let lhsValue = lhs.metrics[metric] ?? 0 + let rhsValue = rhs.metrics[metric] ?? 0 + + // First sort by metric value (descending) + if lhsValue != rhsValue { + return lhsValue > rhsValue + } + + // If values are equal, sort by itemID for stable ordering + return lhs.id.id < rhs.id.id + } + } + + switch item { + case .postsAndPages: + switch metric { + case .views: + let data = try await getData(StatsTopPostsTimeIntervalData.self, parameters: ["skip_archives": "1"]) + let dateFormatter = makeHourlyDateFormatter() + let items = data.topPosts.map { + TopListItem.Post($0, dateFormatter: dateFormatter) + } + return TopListResponse(items: sortItems(items)) + default: + throw StatsServiceError.unavailable + } + + case .referrers: + let data = try await getData(StatsTopReferrersTimeIntervalData.self) + let items = data.referrers.map(TopListItem.Referrer.init) + return TopListResponse(items: sortItems(items)) + + case .locations: + let data = try await getData(StatsTopCountryTimeIntervalData.self) + let items = data.countries.map(TopListItem.Location.init) + return TopListResponse(items: sortItems(items)) + + case .authors: + let data = try await getData(StatsTopAuthorsTimeIntervalData.self) + let dateFormatter = makeHourlyDateFormatter() + let items = data.topAuthors.map { + TopListItem.Author($0, dateFormatter: dateFormatter) + } + return TopListResponse(items: sortItems(items)) + + case .externalLinks: + switch metric { + case .views: + let data = try await getData(StatsTopClicksTimeIntervalData.self) + let items = data.clicks.map(TopListItem.ExternalLink.init) + return TopListResponse(items: sortItems(items)) + default: + throw StatsServiceError.unavailable + } + + case .fileDownloads: + switch metric { + case .downloads: + let data = try await getData(StatsFileDownloadsTimeIntervalData.self) + let items = data.fileDownloads.map(TopListItem.FileDownload.init) + return TopListResponse(items: sortItems(items)) + default: + throw StatsServiceError.unavailable + } + + case .searchTerms: + switch metric { + case .views: + let data = try await getData(StatsSearchTermTimeIntervalData.self) + let items = data.searchTerms.map(TopListItem.SearchTerm.init) + return TopListResponse(items: sortItems(items)) + default: + throw StatsServiceError.unavailable + } + + case .videos: + switch metric { + case .views: + let data = try await getData(StatsTopVideosTimeIntervalData.self) + let items = data.videos.map(TopListItem.Video.init) + return TopListResponse(items: sortItems(items)) + default: + throw StatsServiceError.unavailable + } + + case .archive: + switch metric { + case .views: + let data = try await getData(StatsArchiveTimeIntervalData.self) + let sections = data.summary.compactMap { (sectionName, items) -> TopListItem.ArchiveSection? in + guard !items.isEmpty else { return nil } + return TopListItem.ArchiveSection(sectionName: sectionName, items: items) + } + // Sort sections by total views + let sortedSections = sections.sorted { ($0.metrics.views ?? 0) > ($1.metrics.views ?? 0) } + return TopListResponse(items: sortedSections) + default: + throw StatsServiceError.unavailable + } + } + } + + func getRealtimeTopListData(_ item: TopListItemType) async throws -> TopListResponse { + try await mocks.getRealtimeTopListData(item) + } + + 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( + NSNumber(value: postID), + count: NSNumber(value: count), + before: nil, + excludeUserIDs: nil, + success: { users, found in + let likeUsers = users.map { remoteLike in + PostLikesData.PostLikeUser( + id: remoteLike.userID.intValue, + name: remoteLike.displayName ?? remoteLike.username ?? "", + avatarURL: remoteLike.avatarURL.flatMap(URL.init) + ) + } + let postLikes = PostLikesData(users: likeUsers, totalCount: found.intValue) + continuation.resume(returning: postLikes) + }, + failure: { error in + continuation.resume(throwing: error ?? StatsServiceError.unknown) + } + ) + } + + return result + } + + func getEmailOpens(for postID: Int) async throws -> StatsEmailOpensData { + try await service.getEmailOpens(for: postID) + } + + 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 + /// 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) + } + + /// 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) + guard let endOfToday = calendar.date(byAdding: .day, value: 1, to: startOfToday) else { + return false + } + return interval.end >= startOfToday && interval.start < endOfToday + } + + /// 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 + 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 + } + return output + } + + // 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) -> SiteMetricsResponse { + var calendar = Calendar.current + calendar.timeZone = siteTimeZone + + let now = Date.now + + func makeDataPoint(from data: WordPressKit.StatsSiteMetricsResponse.PeriodData, metric: WordPressKit.StatsSiteMetricsResponse.Metric) -> DataPoint? { + guard let value = data[metric] else { + return nil + } + let date: Date = { + var components = calendar.dateComponents(in: TimeZone.current, from: data.date) + components.timeZone = siteTimeZone + 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) + } + + var total = SiteMetricsSet() + var metrics: [SiteMetric: [DataPoint]] = [:] + for metric in supportedMetrics { + 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) + } + } + return SiteMetricsResponse(total: total, metrics: metrics) + } +} + +enum StatsServiceError: LocalizedError { + case unknown + case unavailable + + var errorDescription: String? { + Strings.Errors.generic + } +} + +// MARK: - Cache + +private struct SiteStatsCacheKey: Hashable { + let interval: DateInterval + let granularity: DateRangeGranularity +} + +private struct CachedEntity { + let data: T + 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 + } +} + +private struct TopListCacheKey: Hashable { + let item: TopListItemType + let metric: SiteMetric + let interval: DateInterval + let granularity: DateRangeGranularity + let limit: Int? +} + +// 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 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, .downloads: return nil + } + } +} + +// MARK: - StatsServiceRemoteV2 Async Extensions + +private extension WordPressKit.StatsServiceRemoteV2 { + func getData( + 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: limit, summarize: summarize, parameters: parameters) { (data: TimeStatsType?, error: Error?) in + if let data { + continuation.resume(returning: data) + } else { + continuation.resume(throwing: error ?? StatsServiceError.unknown) + } + } + } + } + + 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 insight { + continuation.resume(returning: insight) + } else { + continuation.resume(throwing: error ?? StatsServiceError.unknown) + } + } + } + } + + func getDetails(forPostID postID: Int) async throws -> StatsPostDetails { + try await withCheckedThrowingContinuation { continuation in + getDetails(forPostID: postID) { (details: StatsPostDetails?, error: Error?) in + if let details { + continuation.resume(returning: details) + } else { + continuation.resume(throwing: error ?? StatsServiceError.unknown) + } + } + } + } + + func getInsight(limit: Int = 10) async throws -> StatsLastPostInsight { + try await withCheckedThrowingContinuation { continuation in + getInsight(limit: limit) { (insight: StatsLastPostInsight?, error: Error?) in + if let insight { + continuation.resume(returning: insight) + } else { + continuation.resume(throwing: error ?? StatsServiceError.unknown) + } + } + } + } + + 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 getEmailSummaryData( + 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) + } + } + } + } + + func getEmailOpens(for postID: Int) async throws -> StatsEmailOpensData { + try await withCheckedThrowingContinuation { continuation in + getEmailOpens(for: postID) { (data, error) in + if let data { + continuation.resume(returning: data) + } else { + continuation.resume(throwing: error ?? StatsServiceError.unknown) + } + } + } + } +} diff --git a/Modules/Sources/JetpackStats/Services/StatsServiceProtocol.swift b/Modules/Sources/JetpackStats/Services/StatsServiceProtocol.swift new file mode 100644 index 000000000000..6fc06ef5817b --- /dev/null +++ b/Modules/Sources/JetpackStats/Services/StatsServiceProtocol.swift @@ -0,0 +1,17 @@ +import Foundation +@preconcurrency import WordPressKit + +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 -> SiteMetricsResponse + func getTopListData(_ item: TopListItemType, metric: SiteMetric, interval: DateInterval, granularity: DateRangeGranularity, limit: Int?) async throws -> TopListResponse + func getRealtimeTopListData(_ item: TopListItemType) async throws -> TopListResponse + func getPostDetails(for postID: Int) async throws -> StatsPostDetails + func getPostLikes(for postID: Int, count: Int) async throws -> PostLikesData + func getEmailOpens(for postID: Int) async throws -> StatsEmailOpensData + func toggleSpamState(for referrerDomain: String, currentValue: Bool) async throws +} diff --git a/Modules/Sources/JetpackStats/StatsContext.swift b/Modules/Sources/JetpackStats/StatsContext.swift new file mode 100644 index 000000000000..6b31ebd9846e --- /dev/null +++ b/Modules/Sources/JetpackStats/StatsContext.swift @@ -0,0 +1,73 @@ +import Foundation +import SwiftUI +@preconcurrency 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 + let siteID: Int + /// A closure to preprocess avatar URLs to request the appropriate pixel size. + public var preprocessAvatar: (@Sendable (URL, CGFloat) -> URL)? + /// Analytics tracker for monitoring user interactions + public var tracker: (any StatsTracker)? + + public init(timeZone: TimeZone, siteID: Int, api: WordPressComRestApi) { + self.init(timeZone: timeZone, siteID: siteID, service: StatsService(siteID: siteID, api: api, timeZone: timeZone)) + } + + init(timeZone: TimeZone, siteID: Int, service: (any StatsServiceProtocol)) { + self.siteID = siteID + self.timeZone = timeZone + self.calendar = { + var calendar = Calendar.current + calendar.timeZone = timeZone + return calendar + + }() + self.service = service + self.formatters = StatsFormatters(timeZone: timeZone) + self.preprocessAvatar = nil + self.tracker = nil + } + + public static let demo: StatsContext = { + var context = StatsContext(timeZone: .current, siteID: 1, service: MockStatsService()) + #if DEBUG + context.tracker = MockStatsTracker.shared + #endif + return context + }() + + /// 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/StatsRouter.swift b/Modules/Sources/JetpackStats/StatsRouter.swift new file mode 100644 index 000000000000..bc17ce16679c --- /dev/null +++ b/Modules/Sources/JetpackStats/StatsRouter.swift @@ -0,0 +1,99 @@ +import SwiftUI +import UIKit +import SafariServices + +@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 { + @MainActor + var navigationController: UINavigationController? { + let vc = viewController ?? findTopViewController() + return (vc as? UINavigationController) ?? vc?.navigationController + } + + public weak var viewController: UIViewController? + + let factory: StatsRouterScreenFactory + + public init(viewController: UIViewController? = nil, factory: StatsRouterScreenFactory) { + self.viewController = viewController + self.factory = factory + } + + @MainActor + private func findTopViewController() -> UIViewController? { + guard let window = UIApplication.shared.mainWindow else { + return nil + } + var topController = window.rootViewController + while let presented = topController?.presentedViewController { + topController = presented + } + return topController + } + + @MainActor + func navigate(to view: Content, title: String? = nil) { + let viewController = UIHostingController(rootView: view) + if let title { + // This ensures that it gets rendered instantly on navigation + viewController.title = title + } + navigationController?.pushViewController(viewController, animated: true) + } + + @MainActor + func navigateToLikesList(siteID: Int, postID: Int, totalLikes: Int) { + let likesVC = factory.makeLikesListViewController(siteID: siteID, postID: postID, totalLikes: totalLikes) + navigationController?.pushViewController(likesVC, animated: true) + } + + @MainActor + func navigateToCommentsList(siteID: Int, postID: Int) { + 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) + let vc = viewController ?? findTopViewController() + vc?.present(safariViewController, animated: true) + } +} + +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)) + } + + 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(factory: MockStatsRouterScreenFactory()) +} + +extension EnvironmentValues { + var router: StatsRouter { + get { self[StatsRouterKey.self] } + set { self[StatsRouterKey.self] = newValue } + } +} diff --git a/Modules/Sources/JetpackStats/StatsViewModel.swift b/Modules/Sources/JetpackStats/StatsViewModel.swift new file mode 100644 index 000000000000..62c7023e3e20 --- /dev/null +++ b/Modules/Sources/JetpackStats/StatsViewModel.swift @@ -0,0 +1,285 @@ +import SwiftUI +import Combine +import UIKit + +@MainActor +final class StatsViewModel: ObservableObject, CardConfigurationDelegate { + @Published var trafficCardConfiguration: TrafficCardConfiguration + @Published var dateRange: StatsDateRange { + didSet { + updateViewModelsDateRange() + saveSelectedDateRangePreset() + } + } + @Published private(set) var cards: [any TrafficCardViewModel] = [] + + let scrollToCardSubject = PassthroughSubject() + + let context: StatsContext + + private let userDefaults: UserDefaults + private let configurationKey = "JetpackStatsTrafficConfiguration" + private let dateRangePresetKey = "JetpackStatsSelectedDateRangePreset" + + init(context: StatsContext, userDefaults: UserDefaults = .standard) { + self.context = context + self.userDefaults = userDefaults + + // Try to load the saved preset, otherwise use the initial date range + if let savedPreset = Self.loadDateRangePreset(from: userDefaults, key: dateRangePresetKey) { + self.dateRange = context.calendar.makeDateRange(for: savedPreset) + } else { + self.dateRange = context.calendar.makeDateRange(for: .last7Days) + } + + self.trafficCardConfiguration = Self.loadConfiguration( + from: userDefaults, + key: configurationKey, + context: context + ) + configureCards() + } + + func saveConfiguration() { + guard let data = try? JSONEncoder().encode(trafficCardConfiguration) else { return } + userDefaults.set(data, forKey: configurationKey) + } + + func resetToDefault() { + trafficCardConfiguration = makeDefaultConfiguration() + userDefaults.removeObject(forKey: configurationKey) + } + + private static func loadConfiguration(from userDefaults: UserDefaults, key: String, context: StatsContext) -> TrafficCardConfiguration { + guard let data = userDefaults.data(forKey: key), + let configuration = try? JSONDecoder().decode(TrafficCardConfiguration.self, from: data) else { + return makeDefaultConfiguration(context: context) + } + return configuration + } + + private static func makeDefaultConfiguration(context: StatsContext) -> TrafficCardConfiguration { + // Get available metrics from service, excluding downloads + let availableMetrics = context.service.supportedMetrics + + var cards: [TrafficCardConfiguration.Card] = [ + .chart(ChartCardConfiguration(metrics: availableMetrics)) + ] + + if UIDevice.current.userInterfaceIdiom == .pad { // Has more space + cards += [ + .topList(TopListCardConfiguration(item: .postsAndPages, metric: .views)), + .topList(TopListCardConfiguration(item: .referrers, metric: .views)), + .topList(TopListCardConfiguration(item: .searchTerms, metric: .views)), + .topList(TopListCardConfiguration(item: .locations, metric: .views)), + .topList(TopListCardConfiguration(item: .externalLinks, metric: .views)), + .topList(TopListCardConfiguration(item: .authors, metric: .views)), + ] + } else { + cards += [ + .topList(TopListCardConfiguration(item: .postsAndPages, metric: .views)), + .topList(TopListCardConfiguration(item: .referrers, metric: .views)), + .topList(TopListCardConfiguration(item: .locations, metric: .views)) + ] + } + + return TrafficCardConfiguration(cards: cards) + } + + private func makeDefaultConfiguration() -> TrafficCardConfiguration { + Self.makeDefaultConfiguration(context: context) + } + + private func configureCards() { + cards = trafficCardConfiguration.cards.compactMap { card in + createViewModel(for: card) + } + } + + private func createViewModel(for card: TrafficCardConfiguration.Card) -> TrafficCardViewModel? { + let viewModel: TrafficCardViewModel? + + switch card { + case .chart(let configuration): + viewModel = ChartCardViewModel( + configuration: configuration, + dateRange: dateRange, + service: context.service, + tracker: context.tracker + ) + case .topList(let configuration): + viewModel = TopListViewModel( + configuration: configuration, + dateRange: dateRange, + service: context.service, + tracker: context.tracker + ) + } + + viewModel?.configurationDelegate = self + return viewModel + } + + private func updateViewModelsDateRange() { + for card in cards { + card.dateRange = dateRange + } + } + + // MARK: - Adding Cards + + func addCard(type: AddCardType) { + let card = makeCard(type: type) + trafficCardConfiguration.cards.append(card) + saveConfiguration() + + // Track card added event + context.tracker?.send(.cardAdded, properties: ["card_type": cardType(for: card)]) + + // Create and append the view model + if let viewModel = createViewModel(for: card) { + cards.append(viewModel) + + // Enable editing after a short delay to allow the card to be added and scrolled to + Task { + try? await Task.sleep(for: .milliseconds(500)) + scrollToCardSubject.send(viewModel.id) + try? await Task.sleep(for: .milliseconds(500)) + viewModel.isEditing = true + } + } + } + + private func makeCard(type: AddCardType) -> TrafficCardConfiguration.Card { + switch type { + case .chart: + let configuration = ChartCardConfiguration(metrics: context.service.supportedMetrics) + return TrafficCardConfiguration.Card.chart(configuration) + case .topList: + let configuration = TopListCardConfiguration(item: .postsAndPages, metric: .views) + return TrafficCardConfiguration.Card.topList(configuration) + } + } + + // MARK: - CardConfigurationDelegate + + func saveConfiguration(for card: any TrafficCardViewModel) { + // Find the index of the card in configuration + guard let index = trafficCardConfiguration.cards.firstIndex(where: { $0.id == card.id }) else { return } + + // Update the configuration based on the card type + switch card { + case let chartViewModel as ChartCardViewModel: + trafficCardConfiguration.cards[index] = .chart(chartViewModel.configuration) + case let topListViewModel as TopListViewModel: + trafficCardConfiguration.cards[index] = .topList(topListViewModel.configuration) + default: + assertionFailure("Unknown card type") + } + + saveConfiguration() + } + + func deleteCard(_ card: any TrafficCardViewModel) { + // Track card removed event + context.tracker?.send(.cardRemoved, properties: ["card_type": cardType(for: card)]) + + // Find and remove the card from configuration using the protocol's id property + trafficCardConfiguration.cards.removeAll { $0.id == card.id } + + // Remove the card from the view models array + cards.removeAll { $0.id == card.id } + + saveConfiguration() + } + + func moveCard(_ card: any TrafficCardViewModel, direction: MoveDirection) { + // Find the index of the card in both arrays + guard let currentIndex = cards.firstIndex(where: { $0.id == card.id }), + let configIndex = trafficCardConfiguration.cards.firstIndex(where: { $0.id == card.id }) else { + return + } + + let newIndex: Int + switch direction { + case .up: + newIndex = max(0, currentIndex - 1) + case .down: + newIndex = min(cards.count - 1, currentIndex + 1) + case .top: + newIndex = 0 + case .bottom: + newIndex = cards.count - 1 + } + + // If the position hasn't changed, return early + if newIndex == currentIndex { + return + } + + // Move in cards array + let movedCard = cards.remove(at: currentIndex) + cards.insert(movedCard, at: newIndex) + + // Move in configuration array + let movedConfigCard = trafficCardConfiguration.cards.remove(at: configIndex) + trafficCardConfiguration.cards.insert(movedConfigCard, at: newIndex) + + saveConfiguration() + + // Scroll to the moved card after a short delay + Task { + try? await Task.sleep(for: .milliseconds(250)) + scrollToCardSubject.send(card.id) + } + } + + // MARK: - Date Range Persistence + + private func saveSelectedDateRangePreset() { + if let preset = dateRange.preset { + userDefaults.set(preset.rawValue, forKey: dateRangePresetKey) + } else { + userDefaults.removeObject(forKey: dateRangePresetKey) + } + } + + private static func loadDateRangePreset(from userDefaults: UserDefaults, key: String) -> DateIntervalPreset? { + guard let rawValue = userDefaults.string(forKey: key), + let preset = DateIntervalPreset(rawValue: rawValue) else { + return nil + } + return preset + } + + // MARK: - Reset Settings + + /// Resets all persistently stored settings including card configuration and date range preset + func resetAllSettings() { + // Reset card configuration + resetToDefault() + + // Reset date range preset + userDefaults.removeObject(forKey: dateRangePresetKey) + + // Reset date range to default + dateRange = context.calendar.makeDateRange(for: .last7Days) + } + + // MARK: - Helper Methods + + private func cardType(for card: TrafficCardConfiguration.Card) -> String { + switch card { + case .chart: return "chart" + case .topList: return "top_list" + } + } + + private func cardType(for viewModel: any TrafficCardViewModel) -> String { + switch viewModel { + case is ChartCardViewModel: return "chart" + case is TopListViewModel: return "top_list" + default: return "unknown" + } + } +} diff --git a/Modules/Sources/JetpackStats/Strings.swift b/Modules/Sources/JetpackStats/Strings.swift new file mode 100644 index 000000000000..3324a4d09689 --- /dev/null +++ b/Modules/Sources/JetpackStats/Strings.swift @@ -0,0 +1,335 @@ +import Foundation +import WordPressShared + +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 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 last3Years = AppLocalizedString("jetpackStats.calendar.last3Years", value: "Last 3 Years", comment: "Last 3 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 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 { + 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 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") + 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 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") + static let done = AppLocalizedString("jetpackStats.button.done", value: "Done", comment: "Done 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 showMore = AppLocalizedString("jetpackStats.button.showMore", value: "Show More", comment: "Button to expand and show more items") + static let showLess = AppLocalizedString("jetpackStats.button.showLess", value: "Show Less", comment: "Button to collapse and show fewer items") + static let ok = AppLocalizedString("jetpackStats.button.ok", value: "OK", comment: "OK button") + static let downloadCSV = AppLocalizedString("jetpackStats.button.downloadCSV", value: "Download CSV", comment: "Button to download data as CSV file") + static let learnMore = AppLocalizedString("jetpackStats.button.learnMore", value: "Learn More", comment: "Learn more about stats button") + static let addCard = AppLocalizedString("jetpackStats.button.addCard", value: "Add Card", comment: "Button to add a new chart") + static let deleteWidget = AppLocalizedString("jetpackStats.button.deleteWidget", value: "Delete Card", comment: "Button to delete a chart or widget") + static let customize = AppLocalizedString("jetpackStats.button.customize", value: "Edit Card", comment: "Button to customize a chart or widget") + static let resetSettings = AppLocalizedString("jetpackStats.button.resetSettings", value: "Reset Settings", comment: "Button to reset chart settings to default") + static let moveCard = AppLocalizedString("jetpackStats.button.moveCard", value: "Move Card", comment: "Button to move a card") + static let moveUp = AppLocalizedString("jetpackStats.button.moveUp", value: "Move Up", comment: "Button to move card up") + static let moveDown = AppLocalizedString("jetpackStats.button.moveDown", value: "Move Down", comment: "Button to move card down") + static let moveToTop = AppLocalizedString("jetpackStats.button.moveToTop", value: "Move to Top", comment: "Button to move card to the top") + static let moveToBottom = AppLocalizedString("jetpackStats.button.moveToBottom", value: "Move to Bottom", comment: "Button to move card to the bottom") + } + + 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") + 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: "No data for period", comment: "Shown for empty states") + } + + 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 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 { + static let generic = AppLocalizedString("jetpackStats.chart.generitcError", value: "Something went wrong", comment: "Genertic error message") + } + + 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") + + static func itemCount(_ count: Int) -> String { + let format = count == 1 + ? AppLocalizedString("jetpackStats.archiveSections.itemCount.singular", value: "%1$d item", comment: "Singular item count for archive sections. %1$d is the number.") + : AppLocalizedString("jetpackStats.archiveSections.itemCount.plural", value: "%1$d items", comment: "Plural item count for archive sections. %1$d is the number.") + return String.localizedStringWithFormat(format, count) + } + } + + enum PostDetails { + static let title = AppLocalizedString("jetpackStats.postDetails.title", value: "Post Stats", comment: "Navigation title") + 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 + ) + } + + // 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") + + // Email Metrics + static let emailMetrics = AppLocalizedString("jetpackStats.postDetails.emailMetrics", value: "Emails", comment: "Title for email metrics card") + static let emailsSent = AppLocalizedString("jetpackStats.postDetails.emailsSent", value: "Emails Sent", comment: "Must be short!Label for emails sent metric") + static let uniqueOpens = AppLocalizedString("jetpackStats.postDetails.uniqueOpens", value: "Unique Opens", comment: "Must be short!Label for unique email opens metric") + static let totalOpens = AppLocalizedString("jetpackStats.postDetails.totalOpens", value: "Total Opens", comment: "Must be short!Label for total email opens metric") + static let openRate = AppLocalizedString("jetpackStats.postDetails.openRate", value: "Open Rate", comment: "Must be short! Label for email open rate metric") + 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: "Recent Months", comment: "Title for monthly activity heatmap") + + // 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.") + : AppLocalizedString("jetpackStats.postDetails.likes", value: "%1$d likes", comment: "Plural likes count. %1$d is the number.") + return String.localizedStringWithFormat(format, count) + } + + // 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") + } + + enum AuthorDetails { + 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") + 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") + } + + enum ExternalLinkDetails { + static let title = AppLocalizedString("jetpackStats.externalLinkDetails.title", value: "External Link", comment: "Title for the external link details screen") + static let openLink = AppLocalizedString("jetpackStats.externalLinkDetails.openLink", value: "Open Link", comment: "Button to open the external link in browser") + static let childLinks = AppLocalizedString("jetpackStats.externalLinkDetails.childLinks", value: "Sub-links", comment: "Section title for the list of child links") + } + + enum ContextMenuActions { + static let openInBrowser = AppLocalizedString("jetpackStats.contextMenu.openInBrowser", value: "Open in Browser", comment: "Context menu action to open link in browser") + static let copyURL = AppLocalizedString("jetpackStats.contextMenu.copyURL", value: "Copy URL", comment: "Context menu action to copy URL") + static let copyTitle = AppLocalizedString("jetpackStats.contextMenu.copyTitle", value: "Copy Title", comment: "Context menu action to copy title") + static let copyName = AppLocalizedString("jetpackStats.contextMenu.copyName", value: "Copy Name", comment: "Context menu action to copy name") + static let copyDomain = AppLocalizedString("jetpackStats.contextMenu.copyDomain", value: "Copy Domain", comment: "Context menu action to copy domain") + static let copyCountryName = AppLocalizedString("jetpackStats.contextMenu.copyCountryName", value: "Copy Country Name", comment: "Context menu action to copy country name") + static let copyFileName = AppLocalizedString("jetpackStats.contextMenu.copyFileName", value: "Copy File Name", comment: "Context menu action to copy file name") + static let copyFilePath = AppLocalizedString("jetpackStats.contextMenu.copyFilePath", value: "Copy File Path", comment: "Context menu action to copy file path") + static let searchInGoogle = AppLocalizedString("jetpackStats.contextMenu.searchInGoogle", value: "Search in Google", comment: "Context menu action to search term in Google") + static let copySearchTerm = AppLocalizedString("jetpackStats.contextMenu.copySearchTerm", value: "Copy Search Term", comment: "Context menu action to copy search term") + static let copyVideoURL = AppLocalizedString("jetpackStats.contextMenu.copyVideoURL", value: "Copy Video URL", comment: "Context menu action to copy video URL") + } + + enum CSVExport { + static let title = AppLocalizedString("jetpackStats.csv.title", value: "Title", comment: "CSV header for title column") + static let url = AppLocalizedString("jetpackStats.csv.url", value: "URL", comment: "CSV header for URL column") + static let date = AppLocalizedString("jetpackStats.csv.date", value: "Date", comment: "CSV header for date column") + static let type = AppLocalizedString("jetpackStats.csv.type", value: "Type", comment: "CSV header for type column") + static let name = AppLocalizedString("jetpackStats.csv.name", value: "Name", comment: "CSV header for name column") + static let domain = AppLocalizedString("jetpackStats.csv.domain", value: "Domain", comment: "CSV header for domain column") + static let country = AppLocalizedString("jetpackStats.csv.country", value: "Country", comment: "CSV header for country column") + static let countryCode = AppLocalizedString("jetpackStats.csv.countryCode", value: "Country Code", comment: "CSV header for country code column") + static let role = AppLocalizedString("jetpackStats.csv.role", value: "Role", comment: "CSV header for role column") + static let fileName = AppLocalizedString("jetpackStats.csv.fileName", value: "File Name", comment: "CSV header for file name column") + static let filePath = AppLocalizedString("jetpackStats.csv.filePath", value: "File Path", comment: "CSV header for file path column") + static let searchTerm = AppLocalizedString("jetpackStats.csv.searchTerm", value: "Search Term", comment: "CSV header for search term column") + static let videoURL = AppLocalizedString("jetpackStats.csv.videoURL", value: "Video URL", comment: "CSV header for video URL column") + static let section = AppLocalizedString("jetpackStats.csv.section", value: "Section", comment: "CSV header for section column") + } + + enum AddChart { + static let chartOption = AppLocalizedString("jetpackStats.addChart.chartOption", value: "Chart", comment: "Chart option title") + static let chartDescription = AppLocalizedString("jetpackStats.addChart.chartDescription", value: "Visualize trends over time", comment: "Chart option description") + static let topListOption = AppLocalizedString("jetpackStats.addChart.topListOption", value: "Top List", comment: "Top list option title") + static let topListDescription = AppLocalizedString("jetpackStats.addChart.topListDescription", value: "See your top performing content", comment: "Top list option description") + static let selectMetric = AppLocalizedString("jetpackStats.addChart.selectMetric", value: "Select Metrics", comment: "Title for metric selection") + static let selectDataType = AppLocalizedString("jetpackStats.addChart.selectDataType", value: "Select Data Type", comment: "Title for data type selection") + } + + enum Accessibility { + // Tab Bar + static let statsTabBar = AppLocalizedString("jetpackStats.accessibility.statsTabBar", value: "Stats navigation tabs", comment: "Accessibility label for stats tab bar") + static func tabSelected(_ tabName: String) -> String { + String.localizedStringWithFormat( + AppLocalizedString("jetpackStats.accessibility.tabSelected", value: "%1$@ tab selected", comment: "Accessibility announcement when a tab is selected. %1$@ is the tab name."), + tabName + ) + } + static func selectTab(_ tabName: String) -> String { + String.localizedStringWithFormat( + AppLocalizedString("jetpackStats.accessibility.selectTab", value: "Select %1$@ tab", comment: "Accessibility hint for tab selection. %1$@ is the tab name."), + tabName + ) + } + + // Charts + static let chartContainer = AppLocalizedString("jetpackStats.accessibility.chartContainer", value: "Stats chart", comment: "Accessibility label for chart container") + static func chartValue(metric: String, value: String, date: String) -> String { + String.localizedStringWithFormat( + AppLocalizedString("jetpackStats.accessibility.chartValue", value: "%1$@: %2$@ on %3$@", comment: "Chart data point accessibility label. %1$@ is metric name, %2$@ is value, %3$@ is date."), + metric, value, date + ) + } + static func chartTrend(metric: String, trend: String) -> String { + String.localizedStringWithFormat( + AppLocalizedString("jetpackStats.accessibility.chartTrend", value: "%1$@ %2$@", comment: "Chart trend accessibility label. %1$@ is metric name, %2$@ is trend description."), + metric, trend + ) + } + static let viewChartData = AppLocalizedString("jetpackStats.accessibility.viewChartData", value: "View detailed chart data", comment: "Accessibility hint for viewing chart data") + + // Top Lists + static func topListItem(rank: Int, title: String, value: String) -> String { + String.localizedStringWithFormat( + AppLocalizedString("jetpackStats.accessibility.topListItem", value: "Rank %1$d: %2$@, %3$@", comment: "Top list item accessibility label. %1$d is rank, %2$@ is title, %3$@ is value."), + rank, title, value + ) + } + static let viewMoreDetails = AppLocalizedString("jetpackStats.accessibility.viewMoreDetails", value: "Double tap to view more details", comment: "Accessibility hint for items that can show more details") + + // Date Range + static func dateRangeSelected(_ range: String) -> String { + String.localizedStringWithFormat( + AppLocalizedString("jetpackStats.accessibility.dateRangeSelected", value: "Date range: %1$@", comment: "Selected date range accessibility label. %1$@ is the range."), + range + ) + } + static let selectDateRange = AppLocalizedString("jetpackStats.accessibility.selectDateRange", value: "Select date range", comment: "Accessibility hint for date range selection") + static let nextPeriod = AppLocalizedString("jetpackStats.accessibility.nextPeriod", value: "Next period", comment: "Accessibility label for next period navigation button") + static let previousPeriod = AppLocalizedString("jetpackStats.accessibility.previousPeriod", value: "Previous period", comment: "Accessibility label for previous period navigation button") + static let navigateToNextDateRange = AppLocalizedString("jetpackStats.accessibility.navigateToNextDateRange", value: "Navigate to next date range", comment: "Accessibility hint for next period navigation") + static let navigateToPreviousDateRange = AppLocalizedString("jetpackStats.accessibility.navigateToPreviousDateRange", value: "Navigate to previous date range", comment: "Accessibility hint for previous period navigation") + + // Cards + static let addCardButton = AppLocalizedString("jetpackStats.accessibility.addCardButton", value: "Add new stats card", comment: "Accessibility label for add card button") + static func cardTitle(_ title: String) -> String { + String.localizedStringWithFormat( + AppLocalizedString("jetpackStats.accessibility.cardTitle", value: "%1$@ card", comment: "Card title accessibility label. %1$@ is the card title."), + title + ) + } + + // Loading States + static let loadingStats = AppLocalizedString("jetpackStats.accessibility.loadingStats", value: "Loading statistics", comment: "Accessibility label for loading state") + static let statsLoaded = AppLocalizedString("jetpackStats.accessibility.statsLoaded", value: "Statistics loaded", comment: "Accessibility announcement when stats finish loading") + + // Error States + static let errorLoadingStats = AppLocalizedString("jetpackStats.accessibility.errorLoadingStats", value: "Error loading statistics", comment: "Accessibility label for error state") + static let retryLoadingStats = AppLocalizedString("jetpackStats.accessibility.retryLoadingStats", value: "Double tap to retry loading statistics", comment: "Accessibility hint for retry action") + + // Navigation + static let backToStats = AppLocalizedString("jetpackStats.accessibility.backToStats", value: "Back to stats", comment: "Accessibility label for back navigation") + static let openInBrowser = AppLocalizedString("jetpackStats.accessibility.openInBrowser", value: "Open in browser", comment: "Accessibility label for opening link in browser") + static let moreOptions = AppLocalizedString("jetpackStats.accessibility.moreOptions", value: "More options", comment: "Accessibility label for more options menu") + + // Empty States + static let noDataAvailable = AppLocalizedString("jetpackStats.accessibility.noDataAvailable", value: "No data available for this period", comment: "Accessibility label for empty data state") + + // Realtime + static let realtimeVisitorCount = AppLocalizedString("jetpackStats.accessibility.realtimeVisitorCount", value: "Current visitors online", comment: "Accessibility label for realtime visitor count") + static func visitorsNow(_ count: Int) -> String { + let format = count == 1 + ? AppLocalizedString("jetpackStats.accessibility.visitorNow", value: "%1$d visitor online now", comment: "Singular visitor count. %1$d is the number.") + : AppLocalizedString("jetpackStats.accessibility.visitorsNow", value: "%1$d visitors online now", comment: "Plural visitors count. %1$d is the number.") + return String.localizedStringWithFormat(format, count) + } + } + + enum ChartData { + static let title = AppLocalizedString("jetpackStats.chartData.title", value: "Chart Data", comment: "Title for chart data screen") + static let total = AppLocalizedString("jetpackStats.chartData.total", value: "Total", comment: "Label for total value") + static let previous = AppLocalizedString("jetpackStats.chartData.previous", value: "Previous", comment: "Label for previous value") + static let change = AppLocalizedString("jetpackStats.chartData.change", value: "Change", comment: "Label for change value") + static let detailedData = AppLocalizedString("jetpackStats.chartData.detailedData", value: "Detailed Data", comment: "Section title for detailed data") + static let date = AppLocalizedString("jetpackStats.chartData.date", value: "DATE", comment: "Column header for date") + static let value = AppLocalizedString("jetpackStats.chartData.value", value: "VALUE", comment: "Column header for value") + } +} diff --git a/Modules/Sources/JetpackStats/Utilities/DateRangeGranularity.swift b/Modules/Sources/JetpackStats/Utilities/DateRangeGranularity.swift new file mode 100644 index 000000000000..d148d0e73e8f --- /dev/null +++ b/Modules/Sources/JetpackStats/Utilities/DateRangeGranularity.swift @@ -0,0 +1,58 @@ +import Foundation + +enum DateRangeGranularity: Comparable { + 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 under about 4 years, show months + else if totalDays <= 365 * 4 { + 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..0b7aa9cc31db --- /dev/null +++ b/Modules/Sources/JetpackStats/Utilities/Formatters/StatsDateFormatter.swift @@ -0,0 +1,96 @@ +import Foundation + +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, context: Context = .compact) -> String { + let formatter = formatters.formatter(granularity: granularity, context: context) + return formatter.string(from: date) + } + + var formattedTimeOffset: String { + formatters.timeOffset.string(from: .now) + } +} diff --git a/Modules/Sources/JetpackStats/Utilities/Formatters/StatsDateRangeFormatter.swift b/Modules/Sources/JetpackStats/Utilities/Formatters/StatsDateRangeFormatter.swift new file mode 100644 index 000000000000..7e479857fec3 --- /dev/null +++ b/Modules/Sources/JetpackStats/Utilities/Formatters/StatsDateRangeFormatter.swift @@ -0,0 +1,160 @@ +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" + } else { + dateIntervalFormatter.dateTemplate = nil + dateIntervalFormatter.dateStyle = .medium + dateIntervalFormatter.timeStyle = .none + } + + 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..0a4517ad78ca --- /dev/null +++ b/Modules/Sources/JetpackStats/Utilities/Modifiers/CardModifier.swift @@ -0,0 +1,19 @@ +import SwiftUI + +struct CardModifier: ViewModifier { + func body(content: Content) -> some View { + content + .background(Constants.Colors.secondaryBackground) + .overlay( + RoundedRectangle(cornerRadius: 26) + .stroke(Color(.opaqueSeparator), lineWidth: 0.5) + ) + .clipShape(RoundedRectangle(cornerRadius: 26)) + } +} + +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/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/Utilities/Modifiers/PulseAnimationModifier.swift b/Modules/Sources/JetpackStats/Utilities/Modifiers/PulseAnimationModifier.swift new file mode 100644 index 000000000000..cf9ce66a3205 --- /dev/null +++ b/Modules/Sources/JetpackStats/Utilities/Modifiers/PulseAnimationModifier.swift @@ -0,0 +1,39 @@ +import SwiftUI + +struct PulseAnimationModifier: ViewModifier { + let isEnabled: Bool + + func body(content: Content) -> some View { + if isEnabled { + content + .mask { + PulsingMask() + } + } else { + content + } + } +} + +private struct PulsingMask: View { + @State private var opacity: Double = 0.5 + + var body: some View { + Constants.Colors.secondaryBackground + .opacity(opacity) + .onAppear { + withAnimation( + .easeInOut(duration: 1.5) + .repeatForever(autoreverses: true) + ) { + opacity = 0.9 + } + } + } +} + +extension View { + func pulsating(_ isEnabled: Bool = true) -> some View { + modifier(PulseAnimationModifier(isEnabled: isEnabled)) + } +} diff --git a/Modules/Sources/JetpackStats/Utilities/Modifiers/ScrollOffsetModifier.swift b/Modules/Sources/JetpackStats/Utilities/Modifiers/ScrollOffsetModifier.swift new file mode 100644 index 000000000000..07744e74ce32 --- /dev/null +++ b/Modules/Sources/JetpackStats/Utilities/Modifiers/ScrollOffsetModifier.swift @@ -0,0 +1,28 @@ +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 + if isScrolled != newValue { + 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..93c340421bca --- /dev/null +++ b/Modules/Sources/JetpackStats/Utilities/SelectedDataPoints.swift @@ -0,0 +1,62 @@ +import Foundation + +struct SelectedDataPoints { + let current: DataPoint? + let previous: DataPoint? + let unmappedPrevious: DataPoint? + + // Static method to compute selected data points from a date + static func compute( + for date: Date?, + currentSeries: [DataPoint], + previousSeries: [DataPoint], + mappedPreviousSeries: [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 + mappedPreviousSeries) 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 previousPointIndex = mappedPreviousSeries.firstIndex { $0.date == closestDate } + var previousPoint: DataPoint? { + guard let previousPointIndex, mappedPreviousSeries.indices.contains(previousPointIndex) else { return nil } + return mappedPreviousSeries[previousPointIndex] + } + // We need this just to display the data in the tooltip. + var unmappedPrevious: DataPoint? { + guard let previousPointIndex, previousSeries.indices.contains(previousPointIndex) else { return nil } + return previousSeries[previousPointIndex] + } + return SelectedDataPoints(current: currentPoint, previous: previousPoint, unmappedPrevious: unmappedPrevious) + } + + static func compute(for date: Date?, data: ChartData) -> SelectedDataPoints? { + compute( + for: date, + currentSeries: data.currentData, + previousSeries: data.previousData, + mappedPreviousSeries: 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..372f050914d5 --- /dev/null +++ b/Modules/Sources/JetpackStats/Utilities/StatsDateRange.swift @@ -0,0 +1,113 @@ +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 + + /// The preset that was used to create this date range, if any. + var preset: DateIntervalPreset? + + init( + interval: DateInterval, + component: Calendar.Component, + comparison: DateRangeComparisonPeriod = .precedingPeriod, + calendar: Calendar, + preset: DateIntervalPreset? = nil + ) { + self.dateInterval = interval + self.comparison = comparison + self.component = component + self.calendar = calendar + self.preset = preset + self.effectiveComparisonInterval = interval + self.refreshEffectiveComparisonPeriodInterval() + } + + mutating func update(preset: DateIntervalPreset) { + dateInterval = calendar.makeDateInterval(for: preset) + component = preset.component + self.preset = preset + 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) + // When navigating, we lose the preset since it's no longer a standard preset + return StatsDateRange(interval: newInterval, component: component, comparison: comparison, calendar: calendar, preset: nil) + } + + /// Returns true if can navigate in the specified direction. + func canNavigate(in direction: Calendar.NavigationDirection) -> Bool { + calendar.canNavigate(dateInterval, direction: direction) + } + + /// 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, + preset: preset + ) + } +} diff --git a/Modules/Sources/JetpackStats/Utilities/TrendViewModel.swift b/Modules/Sources/JetpackStats/Utilities/TrendViewModel.swift new file mode 100644 index 000000000000..3837fa4508ab --- /dev/null +++ b/Modules/Sources/JetpackStats/Utilities/TrendViewModel.swift @@ -0,0 +1,149 @@ +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: Decimal? { + guard previousValue != 0 else { + return nil + } + return Decimal(abs(currentValue - previousValue)) / Decimal(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 { + if currentValue == 0 && previousValue == 0 { + return "0" + } + guard let percentage else { + return "∞" + } + return percentage.formatted( + .percent + .notation(.compactName) + .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..9e6c51cddb21 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/AvatarView.swift @@ -0,0 +1,57 @@ +import SwiftUI +import WordPressUI +import AsyncImageKit + +struct AvatarView: View { + let name: String + var imageURL: URL? + var size: CGFloat = 36 + var backgroundColor = Color(.systemBackground) + + @Environment(\.context) private var context + @ScaledMetric(relativeTo: .body) private var scaledSize: CGFloat = 36 + + var body: some View { + let avatarSize = min(scaledSize * (size / 36), 72) + + Group { + if let imageURL { + let processedURL = context.preprocessAvatar?(imageURL, avatarSize) ?? imageURL + CachedAsyncImage(url: processedURL) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { + Constants.Colors.background + } + .frame(width: avatarSize, height: avatarSize) + .clipShape(Circle()) + } else { + placeholderView + } + } + .overlay( + RoundedRectangle(cornerRadius: avatarSize / 2) + .stroke(Color(.opaqueSeparator).opacity(0.66), lineWidth: 0.5) + ) + } + + @ViewBuilder + private var placeholderView: some View { + let avatarSize = min(scaledSize * (size / 36), 72) + Circle() + .fill(backgroundColor) + .frame(width: avatarSize, height: avatarSize) + .overlay( + Text(initials) + .font(.system(size: avatarSize * 0.4, weight: .medium)) + .foregroundColor(Color.primary.opacity(0.9)) + ) + } + + 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..02d07e271708 --- /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(.system(.caption, design: .rounded, weight: .semibold)).tracking(-0.25) + } + .foregroundColor(trend.sentiment.foregroundColor) + .padding(.horizontal, 7) + .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..e447b96d569d --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/ChartAxisDateLabel.swift @@ -0,0 +1,64 @@ +import SwiftUI + +struct ChartAxisDateLabel: View { + let date: Date + let granularity: DateRangeGranularity + + @Environment(\.context) var context + + var body: some View { + Group { + if granularity == .hour { + hourLabel + } else { + standardLabel + } + } + .fixedSize() // Prevent from clipping (sometimes happens) + } + + 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..7441e800b6d6 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/ChartLegendView.swift @@ -0,0 +1,37 @@ +import SwiftUI + +struct ChartLegendView: View { + let metric: SiteMetric + let currentPeriod: DateInterval + let previousPeriod: DateInterval + + @Environment(\.context) var context + @ScaledMetric(relativeTo: .footnote) private var circleSize: CGFloat = 6 + + var body: some View { + VStack(alignment: .trailing, spacing: 1) { + // Current period + HStack(spacing: 6) { + Text(context.formatters.dateRange.string(from: currentPeriod)) + .foregroundColor(.primary) + Circle() + .fill(metric.primaryColor) + .frame(width: circleSize, height: circleSize) + } + + // Previous period + HStack(spacing: 6) { + Text(context.formatters.dateRange.string(from: previousPeriod)) + .foregroundColor(.secondary.opacity(0.75)) + .font(.footnote) + Circle() + .fill(Color.secondary.opacity(0.75)) + .frame(width: circleSize, height: circleSize) + } + } + .font(.footnote.weight(.medium)) + .allowsTightening(true) + .lineLimit(1) + .dynamicTypeSize(...DynamicTypeSize.accessibility1) + } +} diff --git a/Modules/Sources/JetpackStats/Views/ChartValueTooltipView.swift b/Modules/Sources/JetpackStats/Views/ChartValueTooltipView.swift new file mode 100644 index 000000000000..5c367f4d492f --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/ChartValueTooltipView.swift @@ -0,0 +1,96 @@ +import SwiftUI + +struct ChartValueTooltipView: View { + let selectedPoints: SelectedDataPoints + let metric: SiteMetric + let granularity: DateRangeGranularity + + @Environment(\.context) var context + + private var currentPoint: DataPoint? { + selectedPoints.current + } + + private var previousPoint: DataPoint? { + selectedPoints.previous + } + + private var unmappedPreviousPoint: DataPoint? { + selectedPoints.unmappedPrevious + } + + private var formattedDate: String? { + guard let date = currentPoint?.date ?? previousPoint?.date else { return nil } + return formattedDate(date) + } + + private func formattedDate(_ date: Date) -> String { + context.formatters.date.formatDate(date, granularity: granularity, context: .regular) + } + + private var trend: TrendViewModel? { + guard let currentPoint, let previousPoint else { + return nil + } + return TrendViewModel( + currentValue: currentPoint.value, + previousValue: previousPoint.value, + metric: metric, + context: .regular + ) + } + + 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: Constants.step1) { + // Legend-style period indicators + VStack(alignment: .leading, spacing: 2) { + if let currentPoint { + HStack(spacing: 8) { + Circle() + .fill(metric.primaryColor) + .frame(width: 8, height: 8) + Text(formattedDate(currentPoint.date)) + .font(.footnote) + .foregroundColor(.primary) + } + } + + if let unmappedPreviousPoint { + HStack(spacing: 8) { + Circle() + .fill(Color.secondary.opacity(0.75)) + .frame(width: 8, height: 8) + Text(formattedDate(unmappedPreviousPoint.date)) + .font(.footnote) + .foregroundColor(.secondary.opacity(0.8)) + } + } + } + + // Summary view + if let trend { + ChartValuesSummaryView(trend: trend, style: .compact) + } else if let previousValue = previousPoint?.value { + Text(StatsValueFormatter(metric: metric).format(value: previousValue, context: .regular)) + .font(.subheadline.weight(.medium)) + } + + if isIncompleteData { + Text(Strings.Chart.incompleteData) + .font(.caption) + .foregroundColor(.secondary) + .padding(.top, -6) + } + } + .fixedSize() + .padding(12) + .background(Color(.systemBackground)) + .cornerRadius(12) + .shadow(color: Constants.Colors.shadowColor, radius: 4, x: 0, y: 2) + } +} diff --git a/Modules/Sources/JetpackStats/Views/ChartValuesSummaryView.swift b/Modules/Sources/JetpackStats/Views/ChartValuesSummaryView.swift new file mode 100644 index 000000000000..a85655b3a878 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/ChartValuesSummaryView.swift @@ -0,0 +1,67 @@ +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: standard + case .compact: compact + } + } + .dynamicTypeSize(...DynamicTypeSize.accessibility1) + } + + 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 { + VStack(alignment: .leading, spacing: 2) { + HStack(alignment: .lastTextBaseline, spacing: 6) { + Text(trend.formattedCurrentValue) + .font(.system(.headline, design: .rounded, weight: .semibold)) + .foregroundColor(.primary) + .contentTransition(.numericText()) + + Text(trend.formattedPreviousValue) + .font(.system(.footnote, design: .rounded)) + .foregroundColor(.secondary.opacity(0.75)).tracking(-0.2) + .contentTransition(.numericText()) + } + + Text(trend.formattedTrendShort2) + .contentTransition(.numericText()) + .font(.system(.footnote, design: .rounded, weight: .medium)).tracking(-0.33) + .foregroundColor(trend.sentiment.foregroundColor) + } + } +} + +#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/CountryMap/CountriesMapData.swift b/Modules/Sources/JetpackStats/Views/CountryMap/CountriesMapData.swift new file mode 100644 index 000000000000..c9ffeb015286 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/CountryMap/CountriesMapData.swift @@ -0,0 +1,51 @@ +import Foundation + +struct CountriesMapData { + let metric: SiteMetric + let minViews: Int + let maxViews: Int + let mapData: [String: Int] + let locations: [TopListItem.Location] + let previousLocations: [String: TopListItem.Location] + + func location(for countryCode: String) -> TopListItem.Location? { + locations.first { $0.countryCode == countryCode } + } + + func previousLocation(for countryCode: String) -> TopListItem.Location? { + previousLocations[countryCode] + } + + init( + metric: SiteMetric, + locations: [TopListItem.Location], + previousLocations: [TopListItemID: TopListItem.Location] = [:] + ) { + self.metric = metric + self.locations = locations + self.previousLocations = { + var output: [String: TopListItem.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.max() ?? 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 + }() + } +} diff --git a/Modules/Sources/JetpackStats/Views/CountryMap/CountriesMapView.swift b/Modules/Sources/JetpackStats/Views/CountryMap/CountriesMapView.swift new file mode 100644 index 000000000000..3825d48cb291 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/CountryMap/CountriesMapView.swift @@ -0,0 +1,124 @@ +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() + } + .dynamicTypeSize(...DynamicTypeSize.xLarge) + } + .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) + } +} + +#Preview { + CountriesMapView( + data: CountriesMapData(metric: .views, locations: [ + TopListItem.Location( + country: "United States", + flag: "🇺🇸", + countryCode: "US", + metrics: SiteMetricsSet(views: 10000) + ), + TopListItem.Location( + country: "United Kingdom", + flag: "🇬🇧", + countryCode: "GB", + metrics: SiteMetricsSet(views: 4000) + ), + TopListItem.Location( + country: "Canada", + flag: "🇨🇦", + countryCode: "CA", + metrics: SiteMetricsSet(views: 2800) + ), + TopListItem.Location( + country: "Germany", + flag: "🇩🇪", + countryCode: "DE", + metrics: SiteMetricsSet(views: 2000) + ), + TopListItem.Location( + country: "Australia", + flag: "🇦🇺", + countryCode: "AU", + metrics: SiteMetricsSet(views: 1600) + ), + TopListItem.Location( + country: "France", + flag: "🇫🇷", + countryCode: "FR", + metrics: SiteMetricsSet(views: 1400) + ), + TopListItem.Location( + country: "Japan", + flag: "🇯🇵", + countryCode: "JP", + metrics: SiteMetricsSet(views: 1100) + ), + TopListItem.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/CountryMap/CountryTooltip.swift b/Modules/Sources/JetpackStats/Views/CountryMap/CountryTooltip.swift new file mode 100644 index 000000000000..1394f5a39ca3 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/CountryMap/CountryTooltip.swift @@ -0,0 +1,152 @@ +import SwiftUI + +struct CountryTooltip: View { + let countryCode: String + let location: TopListItem.Location? + let previousLocation: TopListItem.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: Constants.Colors.shadowColor, 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: TopListItem.Location( + country: "United States", + flag: "🇺🇸", + countryCode: "US", + metrics: SiteMetricsSet(views: 15000) + ), + previousLocation: TopListItem.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/CountryMap/InteractiveMapView.swift b/Modules/Sources/JetpackStats/Views/CountryMap/InteractiveMapView.swift new file mode 100644 index 000000000000..af47b5c6b147 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/CountryMap/InteractiveMapView.swift @@ -0,0 +1,421 @@ +import SwiftUI +import WebKit + +/// A native SwiftUI implementation of an interactive map view that displays SVG maps +/// with data-driven coloring of regions. +/// +/// This view replaces the legacy FSInteractiveMap due to the performance issues +/// with the previous implementation, particularly around rendering and excessive +/// memory usage. +/// +/// ## Implementation Details +/// +/// The view uses WKWebView for rendering SVG content, which is the optimal approach +/// on iOS for several reasons: +/// - **Native SVG Support**: WKWebView provides the most complete and accurate SVG +/// rendering on iOS, supporting all SVG features including complex paths, gradients, +/// and transformations. +/// - **Performance**: WebKit's rendering engine is highly optimized for vector graphics +/// and provides hardware acceleration. +/// - **Memory Efficiency**: Unlike UIKit-based approaches that rasterize SVG to bitmaps, +/// WKWebView maintains the vector nature of the content. +/// - **Smooth Animations**: CSS transitions and transforms are hardware-accelerated. +/// +/// The view processes SVG files by: +/// 1. Loading the SVG resource from the bundle +/// 2. Dynamically updating fill colors based on data values +/// 3. Applying theme-appropriate styling for light/dark modes +/// 4. Wrapping the SVG in minimal HTML for optimal display +/// +/// ## Usage Example +/// ```swift +/// InteractiveMapView( +/// data: ["US": 1000, "GB": 750, "CA": 500], +/// configuration: .init(tintColor: .blue) +/// ) +/// ``` +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: [ + tintColor.lightened(by: 0.75), + tintColor + ], + strokeColor: UIColor(white: 0.8, alpha: 1), + fillColor: UIColor(white: 0.94, alpha: 1) + ) + self.darkStyle = MapStyle( + colorAxis: [ + tintColor.lightened(by: 0.7), + tintColor + ], + strokeColor: UIColor(white: 0.36, alpha: 1), + fillColor: UIColor(white: 0.19, alpha: 1) + ) + } + } + + let svgResourceName: String + let data: [String: Int] + let configuration: Configuration + @Binding var selectedCountryCode: String? + + init( + svgResourceName: String = "world-map", + data: [String: Int], + configuration: Configuration, + selectedCountryCode: Binding + ) { + self.svgResourceName = svgResourceName + self.data = data + self.configuration = configuration + self._selectedCountryCode = selectedCountryCode + } + + @State private var processedSVG: String? + + @Environment(\.colorScheme) private var colorScheme + + private struct Parameters: Equatable { + let data: [String: Int] + let colorScheme: ColorScheme + } + + private var parameters: Parameters { + Parameters(data: data, colorScheme: colorScheme) + } + + var body: some View { + ZStack { + if let processedSVG { + SVGWebView(htmlContent: processedSVG, selectedCountryCode: $selectedCountryCode) + } + } + .task(id: parameters) { + 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( + colorAxis: baseStyle.colorAxis.map { $0.resolvedColor(with: traitCollection) }, + strokeColor: baseStyle.strokeColor.resolvedColor(with: traitCollection), + fillColor: baseStyle.fillColor.resolvedColor(with: traitCollection) + ) + let processedSVGContent = await processSVG( + svgContent: svgContent, + data: parameters.data, + style: resolvedStyle + ) + 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"), + 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) + } +} + +struct MapStyle { + let colorAxis: [UIColor] + let strokeColor: UIColor + let fillColor: UIColor +} + +// 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 + } + return nil +} + +private func processSVG( + svgContent: String, + data: [String: Int], + style: MapStyle +) 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 { + // Handle single data point case where min == max + let normalizedValue: Double + if minValue == maxValue { + normalizedValue = 1.0 // Use max color for single data point + } else { + normalizedValue = Double(value - minValue) / Double(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( + in: result, + options: [], + range: range, + 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 +} + +private func interpolateColor(_ value: Double, colorAxis: [UIColor]) -> UIColor { + // 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 UIColor.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 UIColor.interpolate( + from: colorAxis[lowerIndex], + to: colorAxis[upperIndex], + fraction: fraction + ) + } +} + +// MARK: - SVG WebView + +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.scriptMessageHandler, name: "countrySelected") + + let webView = WKWebView(frame: .zero, configuration: configuration) + webView.isOpaque = false + webView.backgroundColor = .clear + webView.scrollView.backgroundColor = .clear + webView.navigationDelegate = context.coordinator + + // Disable zooming + webView.scrollView.maximumZoomScale = 1.0 + webView.scrollView.minimumZoomScale = 1.0 + webView.scrollView.isMultipleTouchEnabled = false + + webView.alpha = 0 + + context.coordinator.webView = webView + + return webView + } + + func updateUIView(_ webView: WKWebView, context: Context) { + // Force reload by clearing cache when color scheme changes + context.coordinator.setHTML(htmlContent) + } + + class Coordinator: NSObject, WKNavigationDelegate { + @Binding var selectedCountryCode: String? + weak var webView: WKWebView? + + private var htmlContent: String? + private var isReloadNeeded = false + private var lastReloadDate: Date? + + let scriptMessageHandler: ScriptMessageHandler + + init(selectedCountryCode: Binding) { + self._selectedCountryCode = selectedCountryCode + self.scriptMessageHandler = ScriptMessageHandler() + + super.init() + + scriptMessageHandler.coordinator = self + + NotificationCenter.default.addObserver( + self, + selector: #selector(Coordinator.applicationWillEnterForeground), + name: UIApplication.willEnterForegroundNotification, + object: nil + ) + } + + func setHTML(_ html: String) { + self.htmlContent = html + webView?.loadHTMLString(html, baseURL: nil) + } + + 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 webViewWebContentProcessDidTerminate(_ webView: WKWebView) { + isReloadNeeded = true + if UIApplication.shared.applicationState == .active { + reloadIfNeeded() + } + } + + @objc private func applicationWillEnterForeground() { + reloadIfNeeded() + } + + private func reloadIfNeeded() { + guard isReloadNeeded, + Date.now.timeIntervalSince((lastReloadDate ?? .distantPast)) > 8, + let webView, + let htmlContent else { + return + } + isReloadNeeded = false + lastReloadDate = Date() + webView.loadHTMLString(htmlContent, baseURL: nil) + } + + 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 + } + } + } + } + + class ScriptMessageHandler: NSObject, WKScriptMessageHandler { + weak var coordinator: Coordinator? + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + coordinator?.userContentController(userContentController, didReceive: message) + } + } + } +} + +// MARK: - Preview + +#Preview { + InteractiveMapView( + 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 + ], + configuration: .init(tintColor: Constants.Colors.uiColorBlue), selectedCountryCode: .constant(nil) + ) + .frame(height: 230) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Constants.Colors.secondaryBackground) + .dynamicTypeSize(...DynamicTypeSize.xxxLarge) +} diff --git a/Modules/Sources/JetpackStats/Views/CustomDateRangePicker.swift b/Modules/Sources/JetpackStats/Views/CustomDateRangePicker.swift new file mode 100644 index 000000000000..adb32fdaed45 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/CustomDateRangePicker.swift @@ -0,0 +1,273 @@ +import SwiftUI + +struct CustomDateRangePicker: View { + @Binding var dateRange: StatsDateRange + + @State private var startDate: Date + @State private var endDate: Date + + @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.background) + .navigationTitle(Strings.DatePicker.customRange) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(Strings.Buttons.cancel) { dismiss() } + .tint(Color.primary) + } + + ToolbarItem(placement: .confirmationAction) { + Button(Strings.Buttons.apply) { buttonApplyTapped() } + .fontWeight(.semibold) + .buttonStyle(.borderedProminent) + .buttonBorderShape(.capsule) + .tint(Color.primary) + } + } + } + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } + + @ViewBuilder + private var contents: some View { + VStack(spacing: 16) { + VStack(spacing: 16) { + dateSelectionSection + currentSelectionHeader + } + .padding() + .cardStyle() + .padding(.horizontal, Constants.step1) + + quickPeriodPicker + .padding() + } + .padding(.top, 16) + .dynamicTypeSize(...DynamicTypeSize.xLarge) + } + + // 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() + + // Track custom date range selection + let dateFormatter = ISO8601DateFormatter() + dateFormatter.formatOptions = [.withFullDate] + context.tracker?.send(.customDateRangeSelected, properties: [ + "start_date": dateFormatter.string(from: startDate), + "end_date": dateFormatter.string(from: endDate) + ]) + + 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) + TimezoneInfoView() + } + } + + 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 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) + } + + // 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 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 = Calendar.demo.makeDateRange(for: .today) + + 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/Customization/AddCardSheet.swift b/Modules/Sources/JetpackStats/Views/Customization/AddCardSheet.swift new file mode 100644 index 000000000000..aaf65167deff --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/Customization/AddCardSheet.swift @@ -0,0 +1,76 @@ +import SwiftUI + +enum AddCardType { + case chart + case topList +} + +struct AddCardSheet: View { + let onCardTypeSelected: (AddCardType) -> Void + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(spacing: Constants.step1) { + cardTypeSelection + } + + } + + private var cardTypeSelection: some View { + VStack(spacing: Constants.step0_5) { + cardTypeButton( + title: Strings.AddChart.chartOption, + subtitle: Strings.AddChart.chartDescription, + icon: "chart.line.uptrend.xyaxis", + color: Constants.Colors.blue, + action: { + onCardTypeSelected(.chart) + dismiss() + } + ) + + cardTypeButton( + title: Strings.AddChart.topListOption, + subtitle: Strings.AddChart.topListDescription, + icon: "list.number", + color: Constants.Colors.purple, + action: { + onCardTypeSelected(.topList) + dismiss() + } + ) + } + .padding(Constants.step1) + } + + private func cardTypeButton(title: String, subtitle: String, icon: String, color: Color, action: @escaping () -> Void) -> some View { + Button(action: action) { + HStack(spacing: Constants.step1) { + ZStack { + RoundedRectangle(cornerRadius: 10) + .fill(color.opacity(0.1)) + .frame(width: 40, height: 40) + + Image(systemName: icon) + .font(.body) + .foregroundColor(color) + } + + VStack(alignment: .leading, spacing: 1) { + Text(title) + .font(.callout.weight(.semibold)) + .foregroundColor(.primary) + Text(subtitle) + .font(.caption2) + .foregroundColor(.secondary) + } + + Spacer() + } + .padding(.horizontal, Constants.step1) + .padding(.vertical, Constants.step0_5) + .background(Color(UIColor.secondarySystemGroupedBackground)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + } +} diff --git a/Modules/Sources/JetpackStats/Views/Customization/ChartCardCustomizationView.swift b/Modules/Sources/JetpackStats/Views/Customization/ChartCardCustomizationView.swift new file mode 100644 index 000000000000..6314f047ec28 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/Customization/ChartCardCustomizationView.swift @@ -0,0 +1,120 @@ +import SwiftUI + +struct ChartCardCustomizationView: View { + let chartViewModel: ChartCardViewModel + + @State private var selectedMetrics: Set = [] + @State private var metrics: [SiteMetric] = [] + @State private var editMode: EditMode = .active + + @ScaledMetric private var iconWidth = 26 + + @Environment(\.context) var context + @Environment(\.dismiss) var dismiss + + var body: some View { + List { + ForEach(metrics, id: \.self) { metric in + metricRow(metric: metric) + } + .onMove { from, to in + metrics.move(fromOffsets: from, toOffset: to) + } + + // Reset Settings button at the bottom + Section { + Button(action: resetToDefaults) { + Text(Strings.Buttons.resetSettings) + .foregroundColor(.red) + .frame(maxWidth: .infinity, alignment: .center) + + } + .padding(.top, 12) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } + } + .listStyle(.plain) + .environment(\.editMode, $editMode) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(Strings.Buttons.cancel) { + chartViewModel.isEditing = false + dismiss() + } + } + + ToolbarItem(placement: .navigationBarTrailing) { + if !selectedMetrics.isEmpty { + Button(Strings.Buttons.done) { + // Convert selected metrics to array in the order they appear in metrics + let orderedSelectedMetrics = metrics.filter { selectedMetrics.contains($0) } + + // Update existing chart configuration + var updatedConfig = chartViewModel.configuration + updatedConfig.metrics = orderedSelectedMetrics + chartViewModel.updateConfiguration(updatedConfig) + chartViewModel.isEditing = false + + dismiss() + } + .fontWeight(.semibold) + } + } + } + .onAppear { + metrics = context.service.supportedMetrics + + // If editing existing chart, pre-select its current metrics + selectedMetrics = Set(chartViewModel.metrics) + + // Reorder metrics to put selected ones first in their current order + let currentMetrics = chartViewModel.metrics + let otherMetrics = metrics.filter { !currentMetrics.contains($0) } + metrics = currentMetrics + otherMetrics + } + } + + private func metricRow(metric: SiteMetric) -> some View { + Button(action: { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + if selectedMetrics.contains(metric) { + selectedMetrics.remove(metric) + } else { + selectedMetrics.insert(metric) + } + } + }) { + HStack(spacing: Constants.step0_5) { + Image(systemName: selectedMetrics.contains(metric) ? "checkmark.circle.fill" : "circle") + .font(.title3) + .foregroundColor(selectedMetrics.contains(metric) ? .accentColor : Color(.tertiaryLabel)) + .padding(.trailing, 8) + + Image(systemName: metric.systemImage) + .font(.subheadline) + .frame(width: iconWidth) + + Text(metric.localizedTitle) + .font(.body) + .foregroundColor(.primary) + + Spacer() + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + private func resetToDefaults() { + // Get default metrics from service (excluding downloads) + let defaultMetrics = context.service.supportedMetrics.filter { $0 != .downloads } + + // Update selected metrics + selectedMetrics = Set(defaultMetrics) + + // Update the metrics array order to show default metrics first + let otherMetrics = metrics.filter { !defaultMetrics.contains($0) } + metrics = defaultMetrics + otherMetrics + } +} diff --git a/Modules/Sources/JetpackStats/Views/Customization/TopListCardCustomizationView.swift b/Modules/Sources/JetpackStats/Views/Customization/TopListCardCustomizationView.swift new file mode 100644 index 000000000000..576d3b4f212f --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/Customization/TopListCardCustomizationView.swift @@ -0,0 +1,103 @@ +import SwiftUI + +struct TopListCardCustomizationView: View { + let viewModel: TopListViewModel + + @State private var selectedItem: TopListItemType? + @State private var searchText = "" + @State private var editMode: EditMode = .active + + @ScaledMetric private var iconWidth = 26 + + @Environment(\.context) var context + @Environment(\.dismiss) var dismiss + + init(viewModel: TopListViewModel) { + self.viewModel = viewModel + self._selectedItem = State(initialValue: viewModel.configuration.item) + } + + var body: some View { + List { + if !searchText.isEmpty { + filteredItemsList + } else { + ForEach(viewModel.items) { item in + itemRow(item: item) + } + } + } + .listStyle(.plain) + .environment(\.editMode, $editMode) + .searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always)) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button(Strings.Buttons.cancel) { + viewModel.isEditing = false + dismiss() + } + } + } + .onChange(of: selectedItem) { newValue in + if let newValue { + updateConfiguration(with: newValue) + } + } + } + + @ViewBuilder + private var filteredItemsList: some View { + let filteredItems = viewModel.items.filter { item in + item.localizedTitle.localizedCaseInsensitiveContains(searchText) + } + + ForEach(filteredItems) { item in + itemRow(item: item) + } + } + + private func itemRow(item: TopListItemType) -> some View { + Button(action: { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + selectedItem = item + } + }) { + HStack(spacing: Constants.step0_5) { + Image(systemName: item.systemImage) + .font(.subheadline) + .frame(width: iconWidth) + + Text(item.localizedTitle) + .font(.body) + .foregroundColor(.primary) + + Spacer() + + Image(systemName: selectedItem == item ? "checkmark.circle.fill" : "circle") + .font(.title3) + .foregroundColor(selectedItem == item ? .accentColor : Color(.tertiaryLabel)) + .padding(.trailing, 8) + .opacity(selectedItem == item ? 1 : 0) // Reserve space + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + private func updateConfiguration(with item: TopListItemType) { + var updatedConfig = viewModel.configuration + updatedConfig.item = item + + // Adjust metric if current metric is not supported for the new item + let supportedMetrics = context.service.getSupportedMetrics(for: item) + if !supportedMetrics.contains(updatedConfig.metric), + let firstMetric = supportedMetrics.first { + updatedConfig.metric = firstMetric + } + + viewModel.updateConfiguration(updatedConfig) + viewModel.isEditing = false + + dismiss() + } +} diff --git a/Modules/Sources/JetpackStats/Views/EditCardMenuContent.swift b/Modules/Sources/JetpackStats/Views/EditCardMenuContent.swift new file mode 100644 index 000000000000..02912f23eaf2 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/EditCardMenuContent.swift @@ -0,0 +1,52 @@ +import SwiftUI + +struct EditCardMenuContent: View { + let cardViewModel: TrafficCardViewModel + + var body: some View { + Section { + Menu { + ControlGroup { + Button { + cardViewModel.configurationDelegate?.moveCard(cardViewModel, direction: .up) + } label: { + Label(Strings.Buttons.moveUp, systemImage: "arrow.up") + } + + Button { + cardViewModel.configurationDelegate?.moveCard(cardViewModel, direction: .top) + } label: { + Label(Strings.Buttons.moveToTop, systemImage: "arrow.up.to.line") + } + } + + ControlGroup { + Button { + cardViewModel.configurationDelegate?.moveCard(cardViewModel, direction: .down) + } label: { + Label(Strings.Buttons.moveDown, systemImage: "arrow.down") + } + + Button { + cardViewModel.configurationDelegate?.moveCard(cardViewModel, direction: .bottom) + } label: { + Label(Strings.Buttons.moveToBottom, systemImage: "arrow.down.to.line") + } + } + } label: { + Label(Strings.Buttons.moveCard, systemImage: "arrow.up.arrow.down") + } + Button { + cardViewModel.isEditing = true + } label: { + Label(Strings.Buttons.customize, systemImage: "widget.small") + } + Button(role: .destructive) { + cardViewModel.configurationDelegate?.deleteCard(cardViewModel) + } label: { + Label(Strings.Buttons.deleteWidget, systemImage: "trash") + .tint(Color.red) + } + } + } +} diff --git a/Modules/Sources/JetpackStats/Views/Heatmap/HeatmapView.swift b/Modules/Sources/JetpackStats/Views/Heatmap/HeatmapView.swift new file mode 100644 index 000000000000..d63fa211f57e --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/Heatmap/HeatmapView.swift @@ -0,0 +1,94 @@ +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 + + @Environment(\.colorScheme) var colorScheme + + /// 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.value = value + self.formattedValue = formatter.format(value: value, context: .compact) + self.color = metric.primaryColor + self.intensity = intensity + } + + var body: some View { + RoundedRectangle(cornerRadius: Constants.step1) + .fill(Constants.heatmapColor(baseColor: color, intensity: intensity, colorScheme: colorScheme)) + .overlay { + if value > 0 { + Text(formattedValue) + .font(.caption.weight(.medium)) + .foregroundStyle(.primary) + .minimumScaleFactor(0.5) + .lineLimit(1) + .dynamicTypeSize(...DynamicTypeSize.xLarge) + } + } + } +} + +// 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? + + @Environment(\.colorScheme) var colorScheme + + 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 { + 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: Constants.step1) + .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, colorScheme: colorScheme) + } +} diff --git a/Modules/Sources/JetpackStats/Views/Heatmap/WeeklyTrendsView.swift b/Modules/Sources/JetpackStats/Views/Heatmap/WeeklyTrendsView.swift new file mode 100644 index 000000000000..8a2de07dcdcd --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/Heatmap/WeeklyTrendsView.swift @@ -0,0 +1,469 @@ +import SwiftUI +@preconcurrency import WordPressKit + +struct WeeklyTrendsView: View { + let viewModel: WeeklyTrendsViewModel + + private let cellSpacing: CGFloat = 4 + private let weekLabelWidth: CGFloat = 40 + + @State private var selectedDay: DataPoint? + @State private var selectedWeek: Week? + + init(viewModel: WeeklyTrendsViewModel) { + self.viewModel = viewModel + } + + struct Week { + let startDate: Date + 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 -> DataPoint? in + guard let date = calendar.date(from: day.date) else { return nil } + return DataPoint(date: date, value: day.viewsCount) + } + + return Week(startDate: startDate, days: days, averagePerDay: 0) + } + + 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 + heatmap + legend + .padding(.top, Constants.step1) + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + .dynamicTypeSize(...DynamicTypeSize.xxLarge) + } + + 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) + } + } + } + } + + 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) + .dynamicTypeSize(...DynamicTypeSize.large) + + 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: .fill) + } + } + } + } + } + } + + private var legend: some View { + HeatmapLegendView(metric: viewModel.metric, labelWidth: weekLabelWidth) + } +} + +final class WeeklyTrendsViewModel: ObservableObject { + let weeks: [WeeklyTrendsView.Week] + let calendar: Calendar + let metric: SiteMetric + + private let valueFormatter: StatsValueFormatter + private let weekFormatter: DateFormatter + private let aggregator: StatsDataAggregator + + let dayLabels: [String] + let maxValue: Int + + init(dataPoints: [DataPoint], calendar: Calendar, metric: SiteMetric = .views) { + self.calendar = calendar + 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.calendar = calendar + self.weekFormatter.timeZone = calendar.timeZone + + // Cache day labels + 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)]) + 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 = 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 + // 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 completeDays.isEmpty { + averagePerDay = 0 + } else if metric.aggregationStrategy == .average { + averagePerDay = weekTotal + } else { + averagePerDay = weekTotal / completeDays.count + } + return WeeklyTrendsView.Week(startDate: startDate, days: completeDays, averagePerDay: averagePerDay) + } + + // Sort weeks by start date (most recent first) + return weeks.sorted { $0.startDate > $1.startDate } + } + + func weekLabel(for week: WeeklyTrendsView.Week) -> String { + weekFormatter.string(from: week.startDate) + } + + func formatValue(_ value: Int) -> String { + valueFormatter.format(value: value, context: .compact) + } + + 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 day: DataPoint + 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 { + return 0 + } + return min(1.0, Double(value) / Double(maxValue)) + } + + var body: some View { + 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() + .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 struct WeeklyTrendsTooltipView: View { + 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.isEmpty ? nil : DataPoint.getTotalValue(for: week.days, metric: metric) + } + + 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.averagePerDay + } + + private var trendViewModel: TrendViewModel? { + guard let weekTotal, + let previousWeekTotal else { + return nil + } + return 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) + } + + // Week stats + VStack(alignment: .leading, spacing: 4) { + // Week total + 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 + HStack(spacing: 4) { + Text(Strings.PostDetails.dailyAverage) + .font(.caption) + .foregroundColor(.secondary) + Text(formatter.formatValue(averagePerDay)) + .font(.caption) + .fontWeight(.medium) + } + + // Week-over-week change + if let trendViewModel, + let weekTotal, + let previousWeekTotal, + 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) + } +} + +// MARK: - Mock Data + +extension WeeklyTrendsViewModel { + @MainActor + static let mock = WeeklyTrendsViewModel(dataPoints: mockDataPoints(), calendar: .demo) +} + +private func mockDataPoints(weeks: Int = 4) -> [DataPoint] { + let calendar = Calendar.demo + let today = Date() + var dataPoints: [DataPoint] = [] + + for weekOffset in 0.. [DataPoint] { + mockDataPoints(weeks: weeks).map { dataPoint in + DataPoint(date: dataPoint.date, value: Int.random(in: 150...250)) + } +} + +private func mockEmptyDataPoints(weeks: Int = 4) -> [DataPoint] { + mockDataPoints(weeks: weeks).map { dataPoint in + DataPoint(date: dataPoint.date, value: 0) + } +} + +// MARK: - Previews + +#Preview { + ScrollView { + VStack(spacing: Constants.step2) { + WeeklyTrendsView( + viewModel: WeeklyTrendsViewModel( + dataPoints: mockDataPoints(), + calendar: StatsContext.demo.calendar, + metric: .views + ) + ) + .padding(Constants.step2) + .cardStyle() + + WeeklyTrendsView( + viewModel: WeeklyTrendsViewModel( + dataPoints: mockHighTrafficDataPoints(), + calendar: StatsContext.demo.calendar, + metric: .views + ) + ) + .padding(Constants.step2) + .cardStyle() + + WeeklyTrendsView( + viewModel: WeeklyTrendsViewModel( + dataPoints: mockEmptyDataPoints(), + calendar: StatsContext.demo.calendar, + metric: .views + ) + ) + .padding(Constants.step2) + .cardStyle() + } + } + .background(Constants.Colors.background) +} diff --git a/Modules/Sources/JetpackStats/Views/Heatmap/YearlyTrendsView.swift b/Modules/Sources/JetpackStats/Views/Heatmap/YearlyTrendsView.swift new file mode 100644 index 000000000000..9d858ae54efe --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/Heatmap/YearlyTrendsView.swift @@ -0,0 +1,280 @@ +import SwiftUI +@preconcurrency import WordPressKit + +struct YearlyTrendsView: View { + let viewModel: YearlyTrendsViewModel + + private let cellSpacing: CGFloat = 6 + private let yearLabelWidth: CGFloat = 40 + + init(viewModel: YearlyTrendsViewModel) { + self.viewModel = viewModel + } + + var body: some View { + VStack(alignment: .leading, spacing: Constants.step2) { + yearlyHeatmap + legend + } + .frame(maxWidth: .infinity, alignment: .leading) + .dynamicTypeSize(...DynamicTypeSize.xxLarge) + } + + 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) + + HStack(spacing: 8) { + Text(String(year)) + .font(.caption) + .foregroundColor(.secondary) + .frame(width: yearLabelWidth, alignment: .trailing) + .dynamicTypeSize(...DynamicTypeSize.xLarge) + VStack(spacing: cellSpacing) { + // First row: Jul-Dec (top) + HStack(spacing: cellSpacing) { + ForEach(6..<12) { index in + monthCell(dataPoint: monthlyData[index]) + .frame(maxWidth: .infinity) + .aspectRatio(1, contentMode: .fit) + } + } + // Second row: Jan-Jun (bottom) + HStack(spacing: cellSpacing) { + ForEach(0..<6) { index in + monthCell(dataPoint: monthlyData[index]) + .frame(maxWidth: .infinity) + .aspectRatio(1, contentMode: .fit) + } + } + } + } + } + + @ViewBuilder + private func monthCell(dataPoint: DataPoint) -> some View { + MonthCell( + dataPoint: dataPoint, + metric: viewModel.metric, + maxValue: viewModel.maxMonthlyViews, + formatter: viewModel + ) + } + + private var legend: some View { + HeatmapLegendView(metric: viewModel.metric, labelWidth: yearLabelWidth) + } +} + +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 { + 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] = [] + + // 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 + // Sort years in descending order and take only the last 5 years + let allSortedYears = monthlyData.keys.sorted(by: >) + self.sortedYears = Array(allSortedYears.prefix(4)) + 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) + } +} + +private struct MonthCell: View { + let dataPoint: DataPoint + let metric: SiteMetric + let maxValue: Int + let formatter: YearlyTrendsViewModel + + @State private var showingPopover = false + + var body: some View { + HeatmapCellView( + value: dataPoint.value, + metric: metric, + maxValue: maxValue + ) + .onTapGesture { + showingPopover = true + } + .popover(isPresented: $showingPopover) { + MonthlyTrendsTooltipView( + date: dataPoint.date, + value: dataPoint.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: dataPoint.date) + return "\(dateString), \(formatter.formatValue(dataPoint.value)) \(metric.localizedTitle)" + } +} + +private struct MonthlyTrendsTooltipView: View { + let date: Date + 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() + .fill(metric.primaryColor) + .frame(width: 8, height: 8) + Text(formatter.formatValue(value)) + .font(.subheadline) + .fontWeight(.medium) + Text(metric.localizedTitle) + .font(.subheadline) + .foregroundColor(.secondary) + } + } + .padding() + } + + private var formattedDate: String { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "MMMM yyyy" + return dateFormatter.string(from: date) + } +} + +// MARK: - Previews + +#Preview { + ScrollView { + VStack(spacing: Constants.step2) { + YearlyTrendsView( + viewModel: YearlyTrendsViewModel( + dataPoints: mockDataPoints(), + calendar: Calendar.demo, + metric: .views + ) + ) + .padding(Constants.step2) + .cardStyle() + } + } + .background(Constants.Colors.background) +} + +private func mockDataPoints() -> [DataPoint] { + var dataPoints: [DataPoint] = [] + let calendar = Calendar.demo + + 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 +} diff --git a/Modules/Sources/JetpackStats/Views/LegacyFloatingDateControl.swift b/Modules/Sources/JetpackStats/Views/LegacyFloatingDateControl.swift new file mode 100644 index 000000000000..58ad82c3d8a1 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/LegacyFloatingDateControl.swift @@ -0,0 +1,145 @@ +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.xLarge) + .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) + .floatingStyle() + } + .tint(Color.primary) + .menuOrder(.fixed) + .buttonStyle(.plain) + } + + 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 { + if let preset = dateRange.preset, !preset.prefersDateIntervalFormatting { + return preset.localizedString + } + return 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: Constants.Colors.shadowColor, radius: 8, x: 0, y: 4) + .shadow(color: Constants.Colors.shadowColor.opacity(0.5), 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..ba556dd1225a --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/MetricsOverviewTabView.swift @@ -0,0 +1,170 @@ +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 + var onMetricSelected: ((SiteMetric) -> Void)? + + @ScaledMetric(relativeTo: .title) private var minTabWidth: CGFloat = 100 + @Environment(\.horizontalSizeClass) var horizontalSizeClass + + 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) + } + } + } + .padding(.trailing, Constants.step1) // A bit extra after the last item + .dynamicTypeSize(...DynamicTypeSize.xxxLarge) + } + } + } + + private func makeItemView(for item: MetricData, onTap: @escaping () -> Void) -> some View { + MetricItemView(data: item, isSelected: selectedMetric == item.metric, onTap: onTap) + .frame(minWidth: minTabWidth + (horizontalSizeClass == .compact ? 0 : 20)) + .id(item.metric) + } + + private func selectDataType(_ type: SiteMetric, proxy: ScrollViewProxy) { + withAnimation(.spring) { + selectedMetric = type + proxy.scrollTo(type, anchor: .center) + } + UIImpactFeedbackGenerator(style: .soft).impactOccurred() + onMetricSelected?(type) + } +} + +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) { + selectionIndicator + .padding(.leading, Constants.step2) + .padding(.trailing, Constants.step1) + tabContent + .padding(.top, Constants.step1 + 3) + .padding(.bottom, Constants.step2 + 2) + .padding(.leading, Constants.step3) + .animation(.spring, value: isSelected) + } + } + .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)) + .scaleEffect(x: 0.9, y: 0.9) + Text(data.metric.localizedTitle.uppercased()) + .font(.caption.weight(.medium)) + } + .foregroundColor(isSelected ? .primary : .secondary) + .animation(.easeInOut(duration: 0.25), 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(Color.primary) + .frame(height: 3) + .cornerRadius(1.5) + .opacity(isSelected ? 1 : 0) + .scaleEffect(x: isSelected ? 1 : 0.75, anchor: .center) + .animation(.spring, value: isSelected) + } +} + +// 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.background) +} + +#endif diff --git a/Modules/Sources/JetpackStats/Views/SimpleErrorView.swift b/Modules/Sources/JetpackStats/Views/SimpleErrorView.swift new file mode 100644 index 000000000000..77aea13d0d56 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/SimpleErrorView.swift @@ -0,0 +1,25 @@ +import SwiftUI + +struct SimpleErrorView: View { + let message: String + + init(message: String) { + self.message = message + } + + init(error: Error) { + self.message = error.localizedDescription + } + + var body: some View { + Text(message) + .font(.body.weight(.medium)) + .multilineTextAlignment(.center) + .frame(maxWidth: 300) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .dynamicTypeSize(...DynamicTypeSize.xxLarge) + .accessibilityLabel(Strings.Accessibility.errorLoadingStats) + .accessibilityValue(message) + } +} diff --git a/Modules/Sources/JetpackStats/Views/StandaloneMetricView.swift b/Modules/Sources/JetpackStats/Views/StandaloneMetricView.swift new file mode 100644 index 000000000000..680d088e00df --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/StandaloneMetricView.swift @@ -0,0 +1,31 @@ +import SwiftUI +import DesignSystem + +struct StandaloneMetricView: View { + let metric: SiteMetric + let value: Int + + var body: some View { + VStack(alignment: .trailing, spacing: 0) { + HStack(spacing: 4) { + Image(systemName: metric.systemImage) + .font(.caption.weight(.medium)) + .foregroundColor(.secondary) + + Text(metric.localizedTitle) + .font(.caption.weight(.medium)) + .foregroundColor(.secondary) + .textCase(.uppercase) + } + Text(StatsValueFormatter.formatNumber(value, onlyLarge: true)) + .font(Font.make(.recoleta, textStyle: .title2, weight: .medium)) + .foregroundColor(.primary) + .contentTransition(.numericText()) + } + } +} + +#Preview { + StandaloneMetricView(metric: .views, value: 12345) + .padding() +} diff --git a/Modules/Sources/JetpackStats/Views/StatsCardTitleView.swift b/Modules/Sources/JetpackStats/Views/StatsCardTitleView.swift new file mode 100644 index 000000000000..dc3356bee7bd --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/StatsCardTitleView.swift @@ -0,0 +1,83 @@ +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) + .dynamicTypeSize(...DynamicTypeSize.xxxLarge) + } + + @ViewBuilder + private var content: some View { + let title = Text(title) + .font(.headline) + .foregroundColor(.primary) + 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/StatsDateRangeButtons.swift b/Modules/Sources/JetpackStats/Views/StatsDateRangeButtons.swift new file mode 100644 index 000000000000..109913d7b0cc --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/StatsDateRangeButtons.swift @@ -0,0 +1,87 @@ +import SwiftUI + +struct StatsDateRangeButtons: View { + @Binding var dateRange: StatsDateRange + @State private var isShowingCustomRangePicker = false + + var body: some View { + Group { + StatsDatePickerToolbarItem( + dateRange: $dateRange, + isShowingCustomRangePicker: $isShowingCustomRangePicker + ) + .modifier(ProminentMenuModifier()) + .popover(isPresented: $isShowingCustomRangePicker) { + CustomDateRangePicker(dateRange: $dateRange) + .frame(idealWidth: 360) + } + StatsNavigationButton(dateRange: $dateRange, direction: .backward) + .modifier(ProminentMenuModifier()) + StatsNavigationButton(dateRange: $dateRange, direction: .forward) + .modifier(ProminentMenuModifier()) + } + + } +} + +struct StatsDatePickerToolbarItem: View { + @Binding var dateRange: StatsDateRange + @Binding var isShowingCustomRangePicker: Bool + + @Environment(\.context) var context + + var body: some View { + Menu { + StatsDateRangePickerMenu( + selection: $dateRange, + isShowingCustomRangePicker: $isShowingCustomRangePicker + ) + } label: { + Label( + context.formatters.dateRange.string(from: dateRange.dateInterval), + systemImage: "calendar" + ) + } + .labelStyle(.titleAndIcon) + .menuOrder(.fixed) + .accessibilityLabel(Strings.Accessibility.dateRangeSelected(context.formatters.dateRange.string(from: dateRange.dateInterval))) + .accessibilityHint(Strings.Accessibility.selectDateRange) + } +} + +struct StatsNavigationButton: View { + @Binding var dateRange: StatsDateRange + let direction: Calendar.NavigationDirection + + var body: some View { + let isDisabled = !dateRange.canNavigate(in: direction) + + Menu { + ForEach(dateRange.availableAdjacentPeriods(in: direction)) { period in + Button(period.displayText) { + dateRange = period.range + } + } + } label: { + Image(systemName: direction.systemImage) + .foregroundStyle(isDisabled ? Color(.tertiaryLabel) : Color.primary) + } primaryAction: { + dateRange = dateRange.navigate(direction) + } + .opacity(isDisabled ? 0.5 : 1.0) + .disabled(isDisabled) + .accessibilityLabel(direction == .forward ? Strings.Accessibility.nextPeriod : Strings.Accessibility.previousPeriod) + .accessibilityHint(direction == .forward ? Strings.Accessibility.navigateToNextDateRange : Strings.Accessibility.navigateToPreviousDateRange) + } +} + +private struct ProminentMenuModifier: ViewModifier { + func body(content: Content) -> some View { + content + .tint(Color(.tertiaryLabel)) + .foregroundStyle(.primary) + .menuStyle(.button) + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + } +} diff --git a/Modules/Sources/JetpackStats/Views/StatsDateRangePickerMenu.swift b/Modules/Sources/JetpackStats/Views/StatsDateRangePickerMenu.swift new file mode 100644 index 000000000000..b843bbbec661 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/StatsDateRangePickerMenu.swift @@ -0,0 +1,87 @@ +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, + .last3Years, + .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() + + // Track preset selection + context.tracker?.send(.dateRangePresetSelected, properties: [ + "selected_preset": preset.analyticsName + ]) + } + } + } + + 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) + } + } 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..85646867cd47 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/StatsTabBar.swift @@ -0,0 +1,95 @@ +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 + } + } + + var analyticsName: String { + switch self { + case .traffic: return "traffic" + case .realtime: return "realtime" + case .insights: return "insights" + case .subscribers: return "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, Constants.step4) + } + .accessibilityElement(children: .contain) + .accessibilityLabel(Strings.Accessibility.statsTabBar) + Divider() + } + .dynamicTypeSize(...DynamicTypeSize.xxLarge) + .padding(.top, 8) + .background { + backgroundView + } + } + + @ViewBuilder + private func tabButton(for tab: StatsTab) -> some View { + Button(action: { + selectedTab = tab + UIImpactFeedbackGenerator(style: .soft).impactOccurred() + UIAccessibility.post(notification: .announcement, argument: Strings.Accessibility.tabSelected(tab.localizedTitle)) + }) { + 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) + } + .accessibilityLabel(tab.localizedTitle) + .accessibilityHint(Strings.Accessibility.selectTab(tab.localizedTitle)) + .accessibilityAddTraits(selectedTab == tab ? [.isSelected] : []) + } + + 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/TimezoneInfoView.swift b/Modules/Sources/JetpackStats/Views/TimezoneInfoView.swift new file mode 100644 index 000000000000..3333b655211e --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/TimezoneInfoView.swift @@ -0,0 +1,60 @@ +import SwiftUI + +struct TimezoneInfoView: View { + @State private var showingTimezoneInfo = false + @Environment(\.context) private var context + + var body: 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 formattedTimeZone: String { + let name = context.timeZone.localizedName(for: .standard, locale: .current) + return name ?? context.timeZone.identifier + } + + @ViewBuilder + private var timezoneInfoContent: some View { + VStack(alignment: .leading, spacing: 4) { + Text(Strings.DatePicker.siteTimeZone) + .font(.headline) + .foregroundStyle(.primary) + + Text("\(formattedTimeZone) (\(context.formatters.date.formattedTimeOffset))") + .font(.footnote) + .foregroundColor(.secondary) + + Text(Strings.DatePicker.siteTimeZoneDescription) + .font(.subheadline) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 12) + + } + .padding() + .frame(idealWidth: 280, maxWidth: 320) + .modifier(PopoverPresentationModifier()) + } +} + +#Preview { + TimezoneInfoView() + .padding() +} 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..eb73332f600b --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListArchiveItemRowView.swift @@ -0,0 +1,20 @@ +import SwiftUI + +struct TopListArchiveItemRowView: View { + let item: TopListItem.ArchiveItem + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(item.value) + .font(.callout) + .foregroundColor(.primary) + .lineLimit(1) + + Text(item.href) + .font(.footnote) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } +} 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..49755aac0343 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListArchiveSectionRowView.swift @@ -0,0 +1,25 @@ +import SwiftUI + +struct TopListArchiveSectionRowView: View { + let item: TopListItem.ArchiveSection + + var body: some View { + HStack(spacing: Constants.step0_5) { + Image(systemName: "folder") + .font(.callout) + .foregroundColor(.secondary) + .frame(width: 24, alignment: .center) + + VStack(alignment: .leading, spacing: 2) { + Text(item.displayName) + .font(.callout) + .foregroundColor(.primary) + .lineLimit(1) + + Text(Strings.ArchiveSections.itemCount(item.items.count)) + .font(.footnote) + .foregroundColor(.secondary) + } + } + } +} 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..7b0b7e7c272f --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListAuthorRowView.swift @@ -0,0 +1,24 @@ +import SwiftUI + +struct TopListAuthorRowView: View { + let item: TopListItem.Author + + var body: some View { + HStack(spacing: Constants.step0_5) { + AvatarView(name: item.name, imageURL: item.avatarURL) + + VStack(alignment: .leading, spacing: 1) { + Text(item.name) + .font(.body) + .foregroundColor(.primary) + + if 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..9479c5ece7a0 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListExternalLinkRowView.swift @@ -0,0 +1,27 @@ +import SwiftUI + +struct TopListExternalLinkRowView: View { + let item: TopListItem.ExternalLink + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(item.title ?? item.url) + .font(.callout) + .foregroundColor(.primary) + .lineLimit(1) + + if item.children.count > 0 { + Text(Strings.ArchiveSections.itemCount(item.children.count)) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } else { + Text(item.url) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } + } +} 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..d23c92399b89 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListFileDownloadRowView.swift @@ -0,0 +1,13 @@ +import SwiftUI + +struct TopListFileDownloadRowView: View { + let item: TopListItem.FileDownload + + var body: some View { + Text(item.fileName) + .font(.callout) + .foregroundColor(.primary) + .lineLimit(2) + .lineSpacing(-2) + } +} 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..10f12526f5e0 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListLocationRowView.swift @@ -0,0 +1,22 @@ +import SwiftUI + +struct TopListLocationRowView: View { + let item: TopListItem.Location + + var body: some View { + HStack(spacing: Constants.step0_5) { + 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) + } +} 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..13bd7f18b2ca --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListPostRowView.swift @@ -0,0 +1,14 @@ +import SwiftUI +import WordPressShared + +struct TopListPostRowView: View { + let item: TopListItem.Post + + var body: some View { + Text(item.title) + .font(.callout) + .foregroundColor(.primary) + .lineSpacing(-2) + .lineLimit(2) + } +} 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..033439bfae15 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListReferrerRowView.swift @@ -0,0 +1,51 @@ +import SwiftUI +import WordPressUI + +struct TopListReferrerRowView: View { + let item: TopListItem.Referrer + + var body: some View { + HStack(spacing: Constants.step0_5) { + // 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: 1) { + Text(item.name) + .font(.body) + .foregroundColor(.primary) + .lineLimit(1) + + HStack(spacing: 0) { + if let domain = item.domain { + Text(verbatim: domain) + .font(.caption) + } + if !item.children.isEmpty { + let prefix = item.domain == nil ? "" : "," + Text(verbatim: "\(prefix) +\(item.children.count)") + .font(.caption) + } + } + .foregroundColor(.secondary) + .lineLimit(1) + } + } + } + + private var placeholderIcon: some View { + Image(systemName: "link") + } +} 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..774472f8af46 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListSearchTermRowView.swift @@ -0,0 +1,13 @@ +import SwiftUI + +struct TopListSearchTermRowView: View { + let item: TopListItem.SearchTerm + + var body: some View { + Text(item.term) + .font(.callout) + .foregroundColor(.primary) + .lineLimit(2) + .lineSpacing(-2) + } +} 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..adc3dd041e04 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListVideoRowView.swift @@ -0,0 +1,22 @@ +import SwiftUI + +struct TopListVideoRowView: View { + let item: TopListItem.Video + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + (Text(Image(systemName: "play.circle")).font(.footnote) + Text(" ") + Text(item.title)) + .font(.callout) + .foregroundColor(.primary) + .lineLimit(1) + + if let videoURL = item.videoURL?.absoluteString, !videoURL.isEmpty { + Text(videoURL) + .font(.footnote) + .truncationMode(.middle) + .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..6d709826a4c5 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemBarBackground.swift @@ -0,0 +1,40 @@ +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) { + LinearGradient( + colors: [ + barColor.opacity(colorScheme == .light ? 0.06 : 0.22), + barColor.opacity(colorScheme == .light ? 0.12 : 0.35), + ], + startPoint: .leading, + endPoint: .trailing + ) + .mask( + HStack(spacing: 0) { + RoundedRectangle(cornerRadius: Constants.step1) + .frame(width: max(8, barWidth(in: geometry))) + Spacer(minLength: 0) + } + ) + 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+ContextMenu.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView+ContextMenu.swift new file mode 100644 index 000000000000..bb8b950dc4af --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView+ContextMenu.swift @@ -0,0 +1,215 @@ +import SwiftUI +import UIKit + +// MARK: - Context Menu + +extension TopListItemView { + @ViewBuilder + var contextMenuContent: some View { + Group { + // Item-specific actions + switch item { + case let post as TopListItem.Post: + postActions(post) + case let author as TopListItem.Author: + authorActions(author) + case let referrer as TopListItem.Referrer: + referrerActions(referrer) + case let location as TopListItem.Location: + locationActions(location) + case let link as TopListItem.ExternalLink: + externalLinkActions(link) + case let download as TopListItem.FileDownload: + fileDownloadActions(download) + case let searchTerm as TopListItem.SearchTerm: + searchTermActions(searchTerm) + case let video as TopListItem.Video: + videoActions(video) + case let archiveItem as TopListItem.ArchiveItem: + archiveItemActions(archiveItem) + case let archiveSection as TopListItem.ArchiveSection: + archiveSectionActions(archiveSection) + default: + EmptyView() + } + } + } + + // MARK: - Post Actions + + @ViewBuilder + func postActions(_ post: TopListItem.Post) -> some View { + if let url = post.postURL { + Button { + router.openURL(url) + } label: { + Label(Strings.ContextMenuActions.openInBrowser, systemImage: "safari") + } + + Button { + UIPasteboard.general.url = url + } label: { + Label(Strings.ContextMenuActions.copyURL, systemImage: "doc.on.doc") + } + } + + Button { + UIPasteboard.general.string = post.title + } label: { + Label(Strings.ContextMenuActions.copyTitle, systemImage: "doc.on.doc") + } + } + + // MARK: - Author Actions + + @ViewBuilder + func authorActions(_ author: TopListItem.Author) -> some View { + Button { + UIPasteboard.general.string = author.name + } label: { + Label(Strings.ContextMenuActions.copyName, systemImage: "doc.on.doc") + } + } + + // MARK: - Referrer Actions + + @ViewBuilder + func referrerActions(_ referrer: TopListItem.Referrer) -> some View { + if let domain = referrer.domain { + Button { + if let url = URL(string: "https://\(domain)") { + router.openURL(url) + } + } label: { + Label(Strings.ContextMenuActions.openInBrowser, systemImage: "safari") + } + + Button { + UIPasteboard.general.string = domain + } label: { + Label(Strings.ContextMenuActions.copyDomain, systemImage: "doc.on.doc") + } + } + } + + // MARK: - Location Actions + + @ViewBuilder + func locationActions(_ location: TopListItem.Location) -> some View { + Button { + UIPasteboard.general.string = location.country + } label: { + Label(Strings.ContextMenuActions.copyCountryName, systemImage: "doc.on.doc") + } + } + + // MARK: - External Link Actions + + @ViewBuilder + func externalLinkActions(_ link: TopListItem.ExternalLink) -> some View { + if let url = URL(string: link.url) { + Button { + router.openURL(url) + } label: { + Label(Strings.ContextMenuActions.openInBrowser, systemImage: "safari") + } + } + + Button { + UIPasteboard.general.string = link.url + } label: { + Label("Copy URL", systemImage: "doc.on.doc") + } + } + + // MARK: - File Download Actions + + @ViewBuilder + func fileDownloadActions(_ download: TopListItem.FileDownload) -> some View { + Button { + UIPasteboard.general.string = download.fileName + } label: { + Label(Strings.ContextMenuActions.copyFileName, systemImage: "doc.on.doc") + } + + if let path = download.filePath { + Button { + UIPasteboard.general.string = path + } label: { + Label(Strings.ContextMenuActions.copyFilePath, systemImage: "doc.on.doc") + } + } + } + + // MARK: - Search Term Actions + + @ViewBuilder + func searchTermActions(_ searchTerm: TopListItem.SearchTerm) -> some View { + Button { + let query = searchTerm.term.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + if let url = URL(string: "https://www.google.com/search?q=\(query)") { + router.openURL(url) + } + } label: { + Label(Strings.ContextMenuActions.searchInGoogle, systemImage: "magnifyingglass") + } + + Button { + UIPasteboard.general.string = searchTerm.term + } label: { + Label(Strings.ContextMenuActions.copySearchTerm, systemImage: "doc.on.doc") + } + } + + // MARK: - Video Actions + + @ViewBuilder + func videoActions(_ video: TopListItem.Video) -> some View { + if let url = video.videoURL { + Button { + router.openURL(url) + } label: { + Label(Strings.ContextMenuActions.openInBrowser, systemImage: "safari") + } + + Button { + UIPasteboard.general.url = url + } label: { + Label(Strings.ContextMenuActions.copyVideoURL, systemImage: "doc.on.doc") + } + } + + Button { + UIPasteboard.general.string = video.title + } label: { + Label(Strings.ContextMenuActions.copyTitle, systemImage: "doc.on.doc") + } + } + + // MARK: - Archive Item Actions + + @ViewBuilder + func archiveItemActions(_ archiveItem: TopListItem.ArchiveItem) -> some View { + if let url = URL(string: archiveItem.href) { + Button { + router.openURL(url) + } label: { + Label(Strings.ContextMenuActions.openInBrowser, systemImage: "safari") + } + } + + Button { + UIPasteboard.general.string = archiveItem.href + } label: { + Label("Copy URL", systemImage: "doc.on.doc") + } + } + + // MARK: - Archive Section Actions + + @ViewBuilder + func archiveSectionActions(_ section: TopListItem.ArchiveSection) -> some View { + // No specific actions for archive sections + EmptyView() + } +} diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift new file mode 100644 index 000000000000..8ddf33792cdc --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift @@ -0,0 +1,415 @@ +import SwiftUI +import DesignSystem + +struct TopListItemView: View { + static let defaultCellHeight: CGFloat = 52 + + let item: any TopListItemProtocol + let previousValue: Int? + let metric: SiteMetric + let maxValue: Int + let dateRange: StatsDateRange + + @State private var isTapped = false + + /// .title scales the bets in this scenario + @ScaledMetric(relativeTo: .title) private var cellHeight = TopListItemView.defaultCellHeight + @ScaledMetric(relativeTo: .title) private var minTrailingWidth = 74 + + @Environment(\.router) var router + @Environment(\.context) var context + + var body: some View { + if hasDetails { + Button { + // Track item tap + trackItemTap() + + // Trigger animation + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + isTapped = true + } + + // Reset after a delay + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + isTapped = false + } + } + navigateToDetails() + } label: { + content + .contentShape(Rectangle()) // Make the entire view tappable + .scaleEffect(isTapped ? 0.97 : 1.0) + .opacity(isTapped ? 0.85 : 1.0) + } + .buttonStyle(.plain) + .accessibilityHint(Strings.Accessibility.viewMoreDetails) + } else { + content + } + } + + var content: some View { + HStack(alignment: .center, spacing: 0) { + // Content-specific view + switch item { + case let post as TopListItem.Post: + TopListPostRowView(item: post) + case let author as TopListItem.Author: + TopListAuthorRowView(item: author) + case let referrer as TopListItem.Referrer: + TopListReferrerRowView(item: referrer) + case let location as TopListItem.Location: + TopListLocationRowView(item: location) + case let link as TopListItem.ExternalLink: + TopListExternalLinkRowView(item: link) + case let download as TopListItem.FileDownload: + TopListFileDownloadRowView(item: download) + case let searchTerm as TopListItem.SearchTerm: + TopListSearchTermRowView(item: searchTerm) + case let video as TopListItem.Video: + TopListVideoRowView(item: video) + case let archiveItem as TopListItem.ArchiveItem: + TopListArchiveItemRowView(item: archiveItem) + case let archiveSection as TopListItem.ArchiveSection: + TopListArchiveSectionRowView(item: archiveSection) + default: + let _ = assertionFailure("unsupported item: \(item)") + EmptyView() + } + + Spacer(minLength: 6) + + // Metrics view + TopListMetricsView( + currentValue: item.metrics[metric] ?? 0, + previousValue: previousValue, + metric: metric, + showChevron: hasDetails + ) + .frame(minWidth: previousValue == nil ? 20 : minTrailingWidth, alignment: .trailing) + .padding(.trailing, -3) + .dynamicTypeSize(...DynamicTypeSize.xLarge) + } + .dynamicTypeSize(...DynamicTypeSize.xxLarge) + .padding(.horizontal, Constants.step1) + .frame(height: cellHeight) + .contentShape(Rectangle()) + .accessibilityElement(children: .combine) + .contextMenu { + contextMenuContent + } + .background( + TopListItemBarBackground( + value: item.metrics[metric] ?? 0, + maxValue: maxValue, + barColor: metric.primaryColor + ) + ) + } +} + +// MARK: - Private Methods + +private extension TopListItemView { + var hasDetails: Bool { + switch item { + case is TopListItem.Post: + return true + case is TopListItem.ArchiveItem: + return true + case is TopListItem.ArchiveSection: + return true + case is TopListItem.Author: + return true + case is TopListItem.Referrer: + return true + case is TopListItem.ExternalLink: + return true + default: + return false + } + } + + func trackItemTap() { + context.tracker?.send(.topListItemTapped, properties: [ + "item_type": item.id.type.analyticsName, + "metric": metric.analyticsName + ]) + } + + func navigateToDetails() { + switch item { + case let post as TopListItem.Post: + let detailsView = PostStatsView(post: post, dateRange: dateRange) + .environment(\.context, context) + .environment(\.router, router) + router.navigate(to: detailsView, title: Strings.PostDetails.title) + case let archiveItem as TopListItem.ArchiveItem: + if let url = URL(string: archiveItem.href) { + router.openURL(url) + } + case let author as TopListItem.Author: + let detailsView = AuthorStatsView(author: author, initialDateRange: dateRange, context: context) + .environment(\.context, context) + .environment(\.router, router) + router.navigate(to: detailsView, title: Strings.AuthorDetails.title) + case let referrer as TopListItem.Referrer: + let detailsView = ReferrerStatsView(referrer: referrer, dateRange: dateRange) + .environment(\.context, context) + .environment(\.router, router) + router.navigate(to: detailsView, title: Strings.ReferrerDetails.title) + case let archiveSection as TopListItem.ArchiveSection: + let detailsView = ArchiveStatsView(archiveSection: archiveSection, dateRange: dateRange) + .environment(\.context, context) + .environment(\.router, router) + router.navigate(to: detailsView, title: archiveSection.displayName) + case let externalLink as TopListItem.ExternalLink: + let detailsView = ExternalLinkStatsView(externalLink: externalLink, dateRange: dateRange) + .environment(\.context, context) + .environment(\.router, router) + router.navigate(to: detailsView, title: Strings.ExternalLinkDetails.title) + default: + break + } + } +} + +// MARK: - Preview + +#Preview { + ScrollView { + VStack(spacing: 24) { + makePreviewItems() + } + .padding(Constants.step1) + } +} + +@MainActor @ViewBuilder +private func makePreviewItems() -> some View { + // Posts & Pages + VStack(spacing: 8) { + makePreviewItem( + TopListItem.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( + TopListItem.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( + TopListItem.Author( + name: "Sarah Johnson", + userId: "100", + role: nil, // Real API doesn't have roles + metrics: SiteMetricsSet(views: 50000), + avatarURL: Bundle.module.url(forResource: "author4", withExtension: "jpg"), + posts: nil + ), + previousValue: 48000 + ) + + makePreviewItem( + TopListItem.Author( + name: "Michael Chen", + userId: "101", + role: nil, + metrics: SiteMetricsSet(views: 23100), + avatarURL: nil, + posts: nil + ), + previousValue: nil + ) + } + + // Referrers + VStack(spacing: 8) { + makePreviewItem( + TopListItem.Referrer( + name: "Google Search", + domain: "google.com", + iconURL: URL(string: "https://www.google.com/favicon.ico"), + children: [], + metrics: SiteMetricsSet(views: 50000) + ), + previousValue: 42000 + ) + + makePreviewItem( + TopListItem.Referrer( + name: "Direct Traffic", + domain: nil, + iconURL: nil, + children: [], + metrics: SiteMetricsSet(views: 12300) + ), + previousValue: 15000 + ) + } + + // Locations + VStack(spacing: 8) { + makePreviewItem( + TopListItem.Location( + country: "United States", + flag: "🇺🇸", + countryCode: "US", + metrics: SiteMetricsSet(views: 50000) + ), + previousValue: 47500 + ) + + makePreviewItem( + TopListItem.Location( + country: "United Kingdom", + flag: "🇬🇧", + countryCode: "GB", + metrics: SiteMetricsSet(views: 15600) + ), + previousValue: nil + ) + } + + // External Links + VStack(spacing: 8) { + makePreviewItem( + TopListItem.ExternalLink( + url: "https://developer.apple.com/documentation/swiftui", + title: "SwiftUI Documentation", + children: [], + metrics: SiteMetricsSet(views: 50000) + ), + previousValue: 52000 + ) + + makePreviewItem( + TopListItem.ExternalLink( + url: "https://github.com/wordpress/wordpress-ios", + title: nil, + children: [], + metrics: SiteMetricsSet(views: 1250) + ), + previousValue: 1100 + ) + } + + // File Downloads + VStack(spacing: 8) { + makePreviewItem( + TopListItem.FileDownload( + fileName: "wordpress-guide-2024.pdf", + filePath: "/downloads/guides/wordpress-guide-2024.pdf", + metrics: SiteMetricsSet(downloads: 50000) + ), + previousValue: 46000, + metric: .downloads + ) + + makePreviewItem( + TopListItem.FileDownload( + fileName: "sample-theme.zip", + filePath: nil, + metrics: SiteMetricsSet(downloads: 1230) + ), + previousValue: nil, + metric: .downloads + ) + } + + // Search Terms + VStack(spacing: 8) { + makePreviewItem( + TopListItem.SearchTerm( + term: "wordpress tutorial", + metrics: SiteMetricsSet(views: 50000) + ), + previousValue: 48500 + ) + + makePreviewItem( + TopListItem.SearchTerm( + term: "how to install plugins", + metrics: SiteMetricsSet(views: 890) + ), + previousValue: 950 + ) + } + + // Videos + VStack(spacing: 8) { + makePreviewItem( + TopListItem.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( + TopListItem.Video( + title: "Building Your First Theme", + postId: "9013", + videoURL: nil, + metrics: SiteMetricsSet(views: 3210) + ), + previousValue: nil + ) + } + + // Archive Items + VStack(spacing: 8) { + makePreviewItem( + TopListItem.ArchiveItem( + href: "/2024/03/", + value: "March 2024", + metrics: SiteMetricsSet(views: 50000) + ), + previousValue: 51000 + ) + + makePreviewItem( + TopListItem.ArchiveItem( + href: "/category/tutorials/", + value: "Tutorials", + metrics: SiteMetricsSet(views: 12300) + ), + previousValue: 11000 + ) + } +} + +@MainActor +private func makePreviewItem(_ item: any TopListItemProtocol, previousValue: Int? = nil, metric: SiteMetric = .views) -> some View { + TopListItemView( + item: item, + previousValue: previousValue, + metric: metric, + maxValue: 50000, + dateRange: Calendar.demo.makeDateRange(for: .last7Days) + ) +} diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift new file mode 100644 index 000000000000..5ade11730209 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift @@ -0,0 +1,61 @@ +import SwiftUI + +struct TopListItemsView: View { + let data: TopListData + let itemLimit: Int + let dateRange: StatsDateRange + var reserveSpace: Bool = false + + @ScaledMetric(relativeTo: .callout) private var cellHeight = 52 + + var body: some View { + VStack(spacing: Constants.step1 / 2) { + ForEach(Array(data.items.prefix(itemLimit).enumerated()), id: \.element.id) { index, item in + makeView(for: item) + .transition(.move(edge: .leading) + .combined(with: .scale(scale: 0.75)) + .combined(with: .opacity)) + } + + if reserveSpace && data.items.count < itemLimit { + ForEach(0..<(itemLimit - data.items.count), id: \.self) { _ in + PlaceholderRowView(height: cellHeight) + } + } + } + .padding(.horizontal, Constants.step1) + .animation(.spring, value: ObjectIdentifier(data)) + } + + private func makeView(for item: any TopListItemProtocol) -> some View { + TopListItemView( + item: item, + previousValue: data.previousItem(for: item)?.metrics[data.metric], + metric: data.metric, + maxValue: data.metrics.maxValue, + dateRange: dateRange + ) + .frame(height: cellHeight) + } +} + +struct PlaceholderRowView: View { + let height: CGFloat + + var body: some View { + Rectangle() + .fill(Color.clear) + .background( + LinearGradient( + colors: [ + Color.secondary.opacity(0.05), + Color.secondary.opacity(0.02) + ], + startPoint: .leading, + endPoint: .trailing + ) + .clipShape(RoundedRectangle(cornerRadius: Constants.step1)) + ) + .frame(height: height) + } +} diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListMetricsView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListMetricsView.swift new file mode 100644 index 000000000000..ee216faab066 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListMetricsView.swift @@ -0,0 +1,40 @@ +import SwiftUI + +struct TopListMetricsView: View { + let currentValue: Int + let previousValue: Int? + let metric: SiteMetric + var showChevron = false + + var body: some View { + VStack(alignment: .trailing, spacing: 2) { + HStack(spacing: 3) { + Text(StatsValueFormatter.formatNumber(currentValue, onlyLarge: true)) + .font(.system(.subheadline, design: .rounded, weight: .medium)).tracking(-0.1) + .foregroundColor(.primary) + .contentTransition(.numericText()) + + if showChevron { + Image(systemName: "chevron.forward") + .font(.caption2.weight(.bold)) + .foregroundStyle(Color(.tertiaryLabel)) + .padding(.trailing, -2) + } + } + if let trend { + Text(trend.formattedTrend) + .foregroundColor(trend.sentiment.foregroundColor) + .contentTransition(.numericText()) + .font(.system(.caption, design: .rounded, weight: .medium)).tracking(-0.33) + } + } + .animation(.spring, value: trend) + } + + private var trend: TrendViewModel? { + guard let previousValue else { + return nil + } + return TrendViewModel(currentValue: currentValue, previousValue: previousValue, metric: metric) + } +} diff --git a/Modules/Tests/JetpackStatsTests/CSVExporterTests.swift b/Modules/Tests/JetpackStatsTests/CSVExporterTests.swift new file mode 100644 index 000000000000..69a141766e84 --- /dev/null +++ b/Modules/Tests/JetpackStatsTests/CSVExporterTests.swift @@ -0,0 +1,172 @@ +import Foundation +import Testing +@testable import JetpackStats + +struct CSVExporterTests { + + @Test("CSV export generates correct headers and data for posts") + func testCSVExportForPosts() { + // Given + let posts: [TopListItem.Post] = [ + TopListItem.Post( + title: "My First Post", + postID: "123", + postURL: URL(string: "https://example.com/post1"), + date: Date(timeIntervalSince1970: 1700000000), // Nov 14, 2023 + type: "post", + author: "John Doe", + metrics: SiteMetricsSet(views: 150, visitors: 100, likes: 10, comments: 5) + ), + TopListItem.Post( + title: "Another Post, With Comma", + postID: "124", + postURL: URL(string: "https://example.com/post2"), + date: Date(timeIntervalSince1970: 1700100000), // Nov 15, 2023 + type: "page", + author: "Jane \"The Writer\" Smith", + metrics: SiteMetricsSet(views: 300, visitors: 250, likes: 20, comments: 15) + ), + TopListItem.Post( + title: "Post with\nNewline", + postID: "125", + postURL: nil, + date: nil, + type: nil, + author: nil, + metrics: SiteMetricsSet(views: 50, visitors: 40, likes: 2, comments: 1) + ) + ] + + let exporter = CSVExporter() + let metric = SiteMetric.views + + // When + let csv = exporter.generateCSV(from: posts, metric: metric) + + // Then + let lines = csv.split(separator: "\r\n").map(String.init) + + // Verify we have header + 3 data rows + #expect(lines.count == 4) + + // Verify headers + let expectedHeaders = [ + Strings.CSVExport.title, + Strings.CSVExport.url, + Strings.CSVExport.date, + Strings.CSVExport.type, + SiteMetric.views.localizedTitle // The metric's localized title + ].joined(separator: ",") + #expect(lines[0] == expectedHeaders) + + // Verify first post data + #expect(lines[1].contains("My First Post")) + #expect(lines[1].contains("https://example.com/post1")) + #expect(lines[1].contains("post")) + #expect(lines[1].contains("150")) // views count + + // Verify second post data with special characters + #expect(lines[2].contains("\"Another Post, With Comma\"")) // Comma should be escaped + #expect(lines[2].contains("page")) // type + #expect(lines[2].contains("300")) // views count + } + + @Test("CSV export handles different metrics correctly") + func testCSVExportWithDifferentMetrics() { + // Given + let post = TopListItem.Post( + title: "Test Post", + postID: "1", + postURL: URL(string: "https://example.com/test"), + date: Date(), + type: "post", + author: "Test Author", + metrics: SiteMetricsSet( + views: 100, + visitors: 80, + likes: 20, + comments: 10, + posts: 1, + bounceRate: 45, + timeOnSite: 120, + downloads: 5 + ) + ) + + let exporter = CSVExporter() + + // Test with different metrics + let metricsToTest: [(SiteMetric, Int?)] = [ + (.views, 100), + (.visitors, 80), + (.likes, 20), + (.comments, 10), + (.bounceRate, 45), + (.timeOnSite, 120), + (.downloads, 5) + ] + + for (metric, expectedValue) in metricsToTest { + // When + let csv = exporter.generateCSV(from: [post], metric: metric) + let lines = csv.split(separator: "\r\n").map(String.init) + + // Then + #expect(lines.count == 2) // Header + 1 data row + #expect(lines[0].contains(metric.localizedTitle)) + #expect(lines[1].contains("\(expectedValue ?? 0)")) + } + } + + @Test("CSV export handles empty array") + func testCSVExportWithEmptyArray() { + // Given + let exporter = CSVExporter() + let posts: [TopListItem.Post] = [] + + // When + let csv = exporter.generateCSV(from: posts, metric: .views) + + // Then + #expect(csv.isEmpty) + } + + @Test("CSV uses RFC 4180 compliant line endings") + func testCSVLineEndings() { + // Given + let posts: [TopListItem.Post] = [ + TopListItem.Post( + title: "Post 1", + postID: "1", + postURL: nil, + date: nil, + type: nil, + author: nil, + metrics: SiteMetricsSet(views: 1) + ), + TopListItem.Post( + title: "Post 2", + postID: "2", + postURL: nil, + date: nil, + type: nil, + author: nil, + metrics: SiteMetricsSet(views: 2) + ) + ] + + let exporter = CSVExporter() + + // When + let csv = exporter.generateCSV(from: posts, metric: .views) + + // Then + // Verify CRLF line endings are used (RFC 4180 standard) + #expect(csv.contains("\r\n")) + #expect(!csv.contains("\n\r")) // Wrong order + + // Verify we have exactly 2 CRLF sequences (after header and first data row) + let crlfCount = csv.components(separatedBy: "\r\n").count - 1 + #expect(crlfCount == 2) + } +} diff --git a/Modules/Tests/JetpackStatsTests/CalendarNavigationTests.swift b/Modules/Tests/JetpackStatsTests/CalendarNavigationTests.swift new file mode 100644 index 000000000000..377918eb8a49 --- /dev/null +++ b/Modules/Tests/JetpackStatsTests/CalendarNavigationTests.swift @@ -0,0 +1,550 @@ +import Testing +import Foundation +@testable import JetpackStats + +@Suite +struct CalendarNavigationTests { + let calendar = Calendar.mock(timeZone: .eastern) + let now = Date("2025-01-15T14:30:00-03:00") + + // MARK: - Calendar-Based Navigation + + @Test("Navigate single day forward") + func navigateSingleDayForward() { + // GIVEN + let interval = DateInterval( + start: Date("2025-01-15T00:00:00-03:00"), + end: Date("2025-01-16T00:00:00-03:00") + ) + + // WHEN + let result = calendar.navigate(interval, direction: .forward, component: .day) + + // THEN + #expect(result.start == Date("2025-01-16T00:00:00-03:00")) + #expect(result.end == Date("2025-01-17T00:00:00-03:00")) + } + + @Test("Navigate single day backward") + func navigateSingleDayBackward() { + // GIVEN + let interval = DateInterval( + start: Date("2025-01-15T00:00:00-03:00"), + end: Date("2025-01-16T00:00:00-03:00") + ) + + // WHEN + let result = calendar.navigate(interval, direction: .backward, component: .day) + + // THEN + #expect(result.start == Date("2025-01-14T00:00:00-03:00")) + #expect(result.end == Date("2025-01-15T00:00:00-03:00")) + } + + @Test("Navigate month forward") + func navigateMonthForward() { + // GIVEN + let interval = DateInterval( + start: Date("2025-01-01T00:00:00-03:00"), + end: Date("2025-02-01T00:00:00-03:00") + ) + + // WHEN + let result = calendar.navigate(interval, direction: .forward, component: .month) + + // THEN + #expect(result.start == Date("2025-02-01T00:00:00-03:00")) + #expect(result.end == Date("2025-03-01T00:00:00-03:00")) + } + + @Test("Navigate year forward") + func navigateYearForward() { + // GIVEN + let interval = DateInterval( + start: Date("2024-01-01T00:00:00-03:00"), + end: Date("2025-01-01T00:00:00-03:00") + ) + + // WHEN + let result = calendar.navigate(interval, direction: .forward, component: .year) + + // THEN + #expect(result.start == Date("2025-01-01T00:00:00-03:00")) + #expect(result.end == Date("2026-01-01T00:00:00-03:00")) + } + + @Test("Navigate week forward") + func navigateWeekForward() { + // GIVEN + let interval = DateInterval( + start: Date("2025-01-12T00:00:00-03:00"), + end: Date("2025-01-19T00:00:00-03:00") + ) + + // WHEN + let result = calendar.navigate(interval, direction: .forward, component: .weekOfYear) + + // THEN + #expect(result.start == Date("2025-01-19T00:00:00-03:00")) + #expect(result.end == Date("2025-01-26T00:00:00-03:00")) + } + + @Test("Navigate month backward") + func navigateMonthBackward() { + // GIVEN + let interval = DateInterval( + start: Date("2025-02-01T00:00:00-03:00"), + end: Date("2025-03-01T00:00:00-03:00") + ) + + // WHEN + let result = calendar.navigate(interval, direction: .backward, component: .month) + + // THEN + #expect(result.start == Date("2025-01-01T00:00:00-03:00")) + #expect(result.end == Date("2025-02-01T00:00:00-03:00")) + } + + @Test("Navigate year backward") + func navigateYearBackward() { + // GIVEN + let interval = DateInterval( + start: Date("2025-01-01T00:00:00-03:00"), + end: Date("2026-01-01T00:00:00-03:00") + ) + + // WHEN + let result = calendar.navigate(interval, direction: .backward, component: .year) + + // THEN + #expect(result.start == Date("2024-01-01T00:00:00-03:00")) + #expect(result.end == Date("2025-01-01T00:00:00-03:00")) + } + + @Test("Navigate week backward") + func navigateWeekBackward() { + // GIVEN + let interval = DateInterval( + start: Date("2025-01-19T00:00:00-03:00"), + end: Date("2025-01-26T00:00:00-03:00") + ) + + // WHEN + let result = calendar.navigate(interval, direction: .backward, component: .weekOfYear) + + // THEN + #expect(result.start == Date("2025-01-12T00:00:00-03:00")) + #expect(result.end == Date("2025-01-19T00:00:00-03:00")) + } + + // MARK: - Custom Period + + @Test("Navigate custom period (7 days)") + func navigateCustomPeriod() { + // GIVEN - 7 day period + let interval = DateInterval( + start: Date("2025-01-10T00:00:00-03:00"), + end: Date("2025-01-17T00:00:00-03:00") + ) + + // WHEN + let next = calendar.navigate(interval, direction: .forward, component: .day) + let previous = calendar.navigate(interval, direction: .backward, component: .day) + + // THEN - Should shift by exactly 7 days + #expect(next.start == Date("2025-01-17T00:00:00-03:00")) + #expect(next.end == Date("2025-01-24T00:00:00-03:00")) + + #expect(previous.start == Date("2025-01-03T00:00:00-03:00")) + #expect(previous.end == Date("2025-01-10T00:00:00-03:00")) + } + + @Test("Navigate preserves duration", arguments: [ + (3, Date("2025-01-10T00:00:00-03:00"), Date("2025-01-13T00:00:00-03:00")), + (7, Date("2025-01-10T00:00:00-03:00"), Date("2025-01-17T00:00:00-03:00")), + (15, Date("2025-01-10T00:00:00-03:00"), Date("2025-01-25T00:00:00-03:00")), + (30, Date("2025-01-01T00:00:00-03:00"), Date("2025-01-31T00:00:00-03:00")) + ]) + func navigatePreservesDuration(days: Int, startDate: Date, endDate: Date) { + // GIVEN + let interval = DateInterval(start: startDate, end: endDate) + let originalDuration = interval.duration + + // WHEN + let next = calendar.navigate(interval, direction: .forward, component: .day) + let previous = calendar.navigate(interval, direction: .backward, component: .day) + + // THEN - Duration should be preserved (within 1 second tolerance for DST) + #expect(abs(next.duration - originalDuration) < 1) + #expect(abs(previous.duration - originalDuration) < 1) + } + + // MARK: - Can Navigate Tests + + @Test("Can navigate to previous checks year boundary", arguments: [ + (2001, Date("2001-01-15T00:00:00-03:00"), true, 2000), + (2000, Date("2000-01-15T00:00:00-03:00"), false, 2000), + (1999, Date("1999-01-15T00:00:00-03:00"), false, 2000), + (2000, Date("2000-01-15T00:00:00-03:00"), true, 1999), + (1999, Date("1999-01-15T00:00:00-03:00"), false, 1999) + ]) + func canNavigateToPreviousYear(year: Int, date: Date, expectedResult: Bool, minYear: Int) { + // GIVEN + let interval = DateInterval( + start: date, + end: calendar.date(byAdding: .day, value: 1, to: date)! + ) + + // WHEN/THEN + #expect(calendar.canNavigate(interval, direction: .backward, minYear: minYear) == expectedResult) + } + + @Test("Can navigate to next checks against today") + func canNavigateToNextToday() { + // GIVEN + let today = Date("2025-01-10T00:00:00-03:00") + let yesterday = calendar.date(byAdding: .day, value: -1, to: today)! + let tomorrow = calendar.date(byAdding: .day, value: 1, to: today)! + + let intervalYesterday = DateInterval( + start: calendar.startOfDay(for: yesterday), + end: calendar.startOfDay(for: today) + ) + let intervalToday = DateInterval( + start: calendar.startOfDay(for: today), + end: calendar.startOfDay(for: tomorrow) + ) + let intervalTomorrow = DateInterval( + start: calendar.startOfDay(for: tomorrow), + end: calendar.date(byAdding: .day, value: 1, to: tomorrow)! + ) + + // WHEN/THEN + #expect(calendar.canNavigate(intervalYesterday, direction: .forward, now: today)) + #expect(!calendar.canNavigate(intervalToday, direction: .forward, now: today)) + #expect(!calendar.canNavigate(intervalTomorrow, direction: .forward, now: today)) + } + + // MARK: - Preset Navigation Tests + + @Test("Navigate preset intervals correctly", arguments: [ + // 6-month period should navigate by 6 months + (Date("2024-08-01T00:00:00-03:00"), Date("2025-02-01T00:00:00-03:00"), Calendar.Component.month, + Date("2024-02-01T00:00:00-03:00"), Date("2024-08-01T00:00:00-03:00")), + // 12-month period should navigate by 12 months + (Date("2024-02-01T00:00:00-03:00"), Date("2025-02-01T00:00:00-03:00"), Calendar.Component.month, + Date("2023-02-01T00:00:00-03:00"), Date("2024-02-01T00:00:00-03:00")), + // 5-year period should navigate by 5 years + (Date("2021-01-01T00:00:00-03:00"), Date("2026-01-01T00:00:00-03:00"), Calendar.Component.year, + Date("2016-01-01T00:00:00-03:00"), Date("2021-01-01T00:00:00-03:00")), + // 7-day period should navigate by 7 days + (Date("2025-01-08T00:00:00-03:00"), Date("2025-01-15T00:00:00-03:00"), Calendar.Component.day, + Date("2025-01-01T00:00:00-03:00"), Date("2025-01-08T00:00:00-03:00")) + ]) + func navigatePresetIntervals(startDate: Date, endDate: Date, component: Calendar.Component, + expectedPrevStart: Date, expectedPrevEnd: Date) { + // GIVEN - An interval representing a preset period + let interval = DateInterval(start: startDate, end: endDate) + + // WHEN - Navigate backward + let previous = calendar.navigate(interval, direction: .backward, component: component) + + // THEN - Should navigate by the period length + #expect(previous.start == expectedPrevStart) + #expect(previous.end == expectedPrevEnd) + } + + // MARK: - Edge Case Tests + + @Test("Navigate handles boundary transitions", arguments: [ + // Leap year February - 29 days should navigate to another 29-day period + (Date("2024-02-01T00:00:00-03:00"), Date("2024-03-01T00:00:00-03:00"), Date("2024-03-01T00:00:00-03:00"), Date("2024-03-30T00:00:00-03:00")), + // Year boundary - 31 days should navigate to another 31-day period + (Date("2024-12-01T00:00:00-03:00"), Date("2025-01-01T00:00:00-03:00"), Date("2025-01-01T00:00:00-03:00"), Date("2025-02-01T00:00:00-03:00")), + // Week at year boundary - 7 days should navigate to another 7-day period + (Date("2024-12-29T00:00:00-03:00"), Date("2025-01-05T00:00:00-03:00"), Date("2025-01-05T00:00:00-03:00"), Date("2025-01-12T00:00:00-03:00")) + ]) + func navigateBoundaryTransitions(startDate: Date, endDate: Date, expectedStart: Date, expectedEnd: Date) { + // GIVEN + let interval = DateInterval(start: startDate, end: endDate) + + // WHEN - Navigate with day component + let next = calendar.navigate(interval, direction: .forward, component: .day) + + // THEN + #expect(next.start == expectedStart) + #expect(next.end == expectedEnd) + } + + @Test("Navigate partial periods by duration", arguments: [ + // 15-day partial month period + (15, Date("2025-01-10T00:00:00-03:00"), Date("2025-01-25T00:00:00-03:00"), Date("2025-01-25T00:00:00-03:00"), Date("2025-02-09T00:00:00-03:00")), + // 5-day partial week period + (5, Date("2025-01-13T00:00:00-03:00"), Date("2025-01-18T00:00:00-03:00"), Date("2025-01-18T00:00:00-03:00"), Date("2025-01-23T00:00:00-03:00")), + // Custom 10-day period + (10, Date("2025-01-05T00:00:00-03:00"), Date("2025-01-15T00:00:00-03:00"), Date("2025-01-15T00:00:00-03:00"), Date("2025-01-25T00:00:00-03:00")) + ]) + func navigatePartialPeriods(days: Int, startDate: Date, endDate: Date, expectedStart: Date, expectedEnd: Date) { + // GIVEN + let interval = DateInterval(start: startDate, end: endDate) + + // WHEN + let next = calendar.navigate(interval, direction: .forward, component: .day) + + // THEN - Should navigate by the number of days + #expect(next.start == expectedStart) + #expect(next.end == expectedEnd) + + // Verify duration is preserved + #expect(abs(next.duration - interval.duration) < 1) + } + + // MARK: - Navigation Without Components Tests + + @Test("Navigate with component uses calendar-based navigation") + func navigateWithComponentUsesCalendarNavigation() { + // GIVEN - Various intervals that happen to align with calendar boundaries + let fullMonth = DateInterval( + start: Date("2025-02-01T00:00:00-03:00"), + end: Date("2025-03-01T00:00:00-03:00") + ) + let fullYear = DateInterval( + start: Date("2025-01-01T00:00:00-03:00"), + end: Date("2026-01-01T00:00:00-03:00") + ) + + // WHEN - Navigate with appropriate components + let monthNext = calendar.navigate(fullMonth, direction: .forward, component: .month) + let yearNext = calendar.navigate(fullYear, direction: .forward, component: .year) + + // THEN - Should navigate by calendar unit + #expect(monthNext.start == Date("2025-03-01T00:00:00-03:00")) + #expect(monthNext.end == Date("2025-04-01T00:00:00-03:00")) // Next month + + // Year interval navigated by 1 year + #expect(yearNext.start == Date("2026-01-01T00:00:00-03:00")) + #expect(yearNext.end == Date("2027-01-01T00:00:00-03:00")) + } + + // MARK: - Week Navigation Tests + + @Test("Navigate week with component at year boundary") + func navigateWeekYearBoundary() { + // GIVEN - Week that crosses year boundary + let interval = DateInterval( + start: Date("2024-12-29T00:00:00-03:00"), // Sunday + end: Date("2025-01-05T00:00:00-03:00") // Next Sunday + ) + + // WHEN - Navigate with week component + let next = calendar.navigate(interval, direction: .forward, component: .weekOfYear) + let previous = calendar.navigate(interval, direction: .backward, component: .weekOfYear) + + // THEN + #expect(next.start == Date("2025-01-05T00:00:00-03:00")) + #expect(next.end == Date("2025-01-12T00:00:00-03:00")) + + #expect(previous.start == Date("2024-12-22T00:00:00-03:00")) + #expect(previous.end == Date("2024-12-29T00:00:00-03:00")) + } + + @Test("Navigate respects provided calendar parameter") + func navigateRespectsCalendarParameter() { + // GIVEN - Custom period that doesn't align with calendar boundaries + let interval = DateInterval( + start: Date("2025-01-10T12:00:00-03:00"), + end: Date("2025-01-20T12:00:00-03:00") + ) + + // Different calendar with different week start + var mondayCalendar = Calendar.mock(timeZone: .eastern) + mondayCalendar.firstWeekday = 2 // Monday + + var sundayCalendar = Calendar.mock(timeZone: .eastern) + sundayCalendar.firstWeekday = 1 // Sunday + + // WHEN - Navigate with different calendars using day component + let nextWithMondayCalendar = mondayCalendar.navigate(interval, direction: .forward, component: .day) + let nextWithSundayCalendar = sundayCalendar.navigate(interval, direction: .forward, component: .day) + + // THEN - Since we're using day component, both should navigate by 10 days + #expect(nextWithMondayCalendar.start == Date("2025-01-20T12:00:00-03:00")) + #expect(nextWithMondayCalendar.end == Date("2025-01-30T12:00:00-03:00")) + + #expect(nextWithSundayCalendar.start == Date("2025-01-20T12:00:00-03:00")) + #expect(nextWithSundayCalendar.end == Date("2025-01-30T12:00:00-03:00")) + } + + // MARK: - Determine Navigation Component Tests + + @Test("Determine navigation component for single day") + func determineNavigationComponentSingleDay() { + // GIVEN + let interval = DateInterval( + start: Date("2025-01-15T00:00:00-03:00"), + end: Date("2025-01-16T00:00:00-03:00") + ) + + // WHEN + let component = calendar.determineNavigationComponent(for: interval) + + // THEN + #expect(component == .day) + } + + @Test("Determine navigation component for complete month", arguments: [ + (Date("2025-01-01T00:00:00-03:00"), Date("2025-02-01T00:00:00-03:00")), // January + (Date("2025-02-01T00:00:00-03:00"), Date("2025-03-01T00:00:00-03:00")), // February (non-leap) + (Date("2024-02-01T00:00:00-03:00"), Date("2024-03-01T00:00:00-03:00")), // February (leap) + (Date("2025-04-01T00:00:00-03:00"), Date("2025-05-01T00:00:00-03:00")), // April (30 days) + (Date("2025-12-01T00:00:00-03:00"), Date("2026-01-01T00:00:00-03:00")) // December + ]) + func determineNavigationComponentMonth(startDate: Date, endDate: Date) { + // GIVEN + let interval = DateInterval(start: startDate, end: endDate) + + // WHEN + let component = calendar.determineNavigationComponent(for: interval) + + // THEN + #expect(component == .month) + } + + @Test("Determine navigation component for complete year", arguments: [ + (Date("2025-01-01T00:00:00-03:00"), Date("2026-01-01T00:00:00-03:00")), // Regular year + (Date("2024-01-01T00:00:00-03:00"), Date("2025-01-01T00:00:00-03:00")), // Leap year + (Date("2023-01-01T00:00:00-03:00"), Date("2024-01-01T00:00:00-03:00")) // Year before leap + ]) + func determineNavigationComponentYear(startDate: Date, endDate: Date) { + // GIVEN + let interval = DateInterval(start: startDate, end: endDate) + + // WHEN + let component = calendar.determineNavigationComponent(for: interval) + + // THEN + #expect(component == .year) + } + + @Test("Determine navigation component for complete week") + func determineNavigationComponentWeek() { + // GIVEN - Full week (Sunday to Sunday in eastern calendar) + let interval = DateInterval( + start: Date("2025-01-12T00:00:00-03:00"), // Sunday + end: Date("2025-01-19T00:00:00-03:00") // Next Sunday + ) + + // WHEN + let component = calendar.determineNavigationComponent(for: interval) + + // THEN + #expect(component == .weekOfYear) + } + + @Test("Determine navigation component for week at year boundary") + func determineNavigationComponentWeekYearBoundary() { + // GIVEN - Week that crosses year boundary + let interval = DateInterval( + start: Date("2024-12-29T00:00:00-03:00"), // Sunday + end: Date("2025-01-05T00:00:00-03:00") // Next Sunday + ) + + // WHEN + let component = calendar.determineNavigationComponent(for: interval) + + // THEN + #expect(component == .weekOfYear) + } + + @Test("Determine navigation component for custom periods returns nil", arguments: [ + // 3 days + (Date("2025-01-10T00:00:00-03:00"), Date("2025-01-13T00:00:00-03:00")), + // 10 days + (Date("2025-01-10T00:00:00-03:00"), Date("2025-01-20T00:00:00-03:00")), + // 15 days (partial month) + (Date("2025-01-10T00:00:00-03:00"), Date("2025-01-25T00:00:00-03:00")), + // 5 days (partial week) + (Date("2025-01-13T00:00:00-03:00"), Date("2025-01-18T00:00:00-03:00")), + // Partial month starting mid-month + (Date("2025-01-15T00:00:00-03:00"), Date("2025-02-15T00:00:00-03:00")), + // 6 months (half year) + (Date("2025-01-01T00:00:00-03:00"), Date("2025-07-01T00:00:00-03:00")) + ]) + func determineNavigationComponentCustomPeriods(startDate: Date, endDate: Date) { + // GIVEN + let interval = DateInterval(start: startDate, end: endDate) + + // WHEN + let component = calendar.determineNavigationComponent(for: interval) + + // THEN + #expect(component == nil) + } + + @Test("Determine navigation component with time offsets") + func determineNavigationComponentTimeOffsets() { + // GIVEN - Month interval with slight time offsets (within 1 second tolerance) + let start = Date("2025-01-01T00:00:00-03:00").addingTimeInterval(0.5) // 0.5 seconds offset + let end = Date("2025-02-01T00:00:00-03:00").addingTimeInterval(0.5) + let intervalWithOffset = DateInterval(start: start, end: end) + + // WHEN + let component = calendar.determineNavigationComponent(for: intervalWithOffset) + + // THEN - Should still recognize as month + #expect(component == .month) + } + + @Test("Determine navigation component rejects intervals beyond tolerance") + func determineNavigationComponentBeyondTolerance() { + // GIVEN - Month interval with time offset beyond 1 second + let intervalBeyondTolerance = DateInterval( + start: Date("2025-01-01T00:00:02-03:00"), // 2 seconds offset + end: Date("2025-02-01T00:00:00-03:00") + ) + + // WHEN + let component = calendar.determineNavigationComponent(for: intervalBeyondTolerance) + + // THEN - Should not recognize as month + #expect(component == nil) + } + + @Test("Determine navigation component for different calendar configurations") + func determineNavigationComponentDifferentCalendars() { + // GIVEN + var mondayCalendar = Calendar.mock(timeZone: .eastern) + mondayCalendar.firstWeekday = 2 // Monday + + // Week starting on Monday + let mondayWeekInterval = DateInterval( + start: Date("2025-01-13T00:00:00-03:00"), // Monday + end: Date("2025-01-20T00:00:00-03:00") // Next Monday + ) + + // WHEN + let component = mondayCalendar.determineNavigationComponent(for: mondayWeekInterval) + + // THEN + #expect(component == .weekOfYear) + } + + @Test("Determine navigation component edge cases") + func determineNavigationComponentEdgeCases() { + // GIVEN - Zero duration interval (same start and end) + let zeroDuration = DateInterval( + start: Date("2025-01-15T00:00:00-03:00"), + end: Date("2025-01-15T00:00:00-03:00") + ) + + // Almost a day (23 hours 59 minutes 59 seconds) + let almostDay = DateInterval( + start: Date("2025-01-15T00:00:00-03:00"), + end: Date("2025-01-15T23:59:59-03:00") + ) + + // WHEN/THEN + #expect(calendar.determineNavigationComponent(for: zeroDuration) == nil) + #expect(calendar.determineNavigationComponent(for: almostDay) == .day) + } +} diff --git a/Modules/Tests/JetpackStatsTests/CalendarPresetsTests.swift b/Modules/Tests/JetpackStatsTests/CalendarPresetsTests.swift new file mode 100644 index 000000000000..66f7a8ef26e0 --- /dev/null +++ b/Modules/Tests/JetpackStatsTests/CalendarPresetsTests.swift @@ -0,0 +1,239 @@ +import Testing +import Foundation +@testable import JetpackStats + +@Suite +struct CalendarDateRangePresetTests { + let calendar = Calendar.mock(timeZone: .eastern) + + @Test("Date range presets", arguments: [ + (DateIntervalPreset.today, Date("2025-01-15T00:00:00-03:00"), Date("2025-01-16T00:00:00-03:00")), + (.thisWeek, Date("2025-01-12T00:00:00-03:00"), Date("2025-01-19T00:00:00-03:00")), + (.thisMonth, Date("2025-01-01T00:00:00-03:00"), Date("2025-02-01T00:00:00-03:00")), + (.thisQuarter, Date("2025-01-01T00:00:00-03:00"), Date("2025-04-01T00:00:00-03:00")), + (.thisYear, Date("2025-01-01T00:00:00-03:00"), Date("2026-01-01T00:00:00-03:00")), + (.last7Days, Date("2025-01-08T00:00:00-03:00"), Date("2025-01-15T00:00:00-03:00")), + (.last28Days, Date("2024-12-18T00:00:00-03:00"), Date("2025-01-15T00:00:00-03:00")), + (.last30Days, Date("2024-12-16T00:00:00-03:00"), Date("2025-01-15T00:00:00-03:00")), + (.last90Days, Date("2024-10-17T00:00:00-03:00"), Date("2025-01-15T00:00:00-03:00")), + (.last6Months, Date("2024-08-01T00:00:00-03:00"), Date("2025-02-01T00:00:00-03:00")), + (.last12Months, Date("2024-02-01T00:00:00-03:00"), Date("2025-02-01T00:00:00-03:00")), + (.last3Years, Date("2023-01-01T00:00:00-03:00"), Date("2026-01-01T00:00:00-03:00")), + (.last10Years, Date("2016-01-01T00:00:00-03:00"), Date("2026-01-01T00:00:00-03:00")) + ]) + func dateRangePresets(preset: DateIntervalPreset, expectedStart: Date, expectedEnd: Date) { + // GIVEN + let now = Date("2025-01-15T14:30:00-03:00") + let interval = calendar.makeDateInterval(for: preset, now: now) + + // THEN + #expect(interval.start == expectedStart) + #expect(interval.end == expectedEnd) + } + + // MARK: - Edge Cases + + @Test("Preset handles leap year February correctly") + func presetLeapYearFebruary() { + // GIVEN - Date in February of leap year + let now = Date("2024-02-15T14:30:00-03:00") + + // WHEN + let monthToDate = calendar.makeDateInterval(for: .thisMonth, now: now) + + // THEN - Should include all 29 days + #expect(monthToDate.start == Date("2024-02-01T00:00:00-03:00")) + #expect(monthToDate.end == Date("2024-03-01T00:00:00-03:00")) + } + + @Test("Preset handles year boundary correctly", arguments: [ + (DateIntervalPreset.last7Days, Date("2024-12-29T00:00:00-03:00"), Date("2025-01-05T00:00:00-03:00")), + (DateIntervalPreset.last30Days, Date("2024-12-06T00:00:00-03:00"), Date("2025-01-05T00:00:00-03:00")) + ]) + func presetYearBoundary(preset: DateIntervalPreset, expectedStart: Date, expectedEnd: Date) { + // GIVEN + let now = Date("2025-01-05T14:30:00-03:00") + + // WHEN + let interval = calendar.makeDateInterval(for: preset, now: now) + + // THEN + #expect(interval.start == expectedStart) + #expect(interval.end == expectedEnd) + } + + @Test("Preset handles daylight saving time transitions") + func presetDSTTransition() { + // GIVEN - Using UTC to avoid DST complications in tests + let utcCalendar = Calendar.mock(timeZone: TimeZone(secondsFromGMT: 0)!) + let beforeDST = Date("2024-03-09T12:00:00Z") // Day before DST in US + let afterDST = Date("2024-03-11T12:00:00Z") // Day after DST in US + + // WHEN + let intervalBefore = utcCalendar.makeDateInterval(for: .today, now: beforeDST) + let intervalAfter = utcCalendar.makeDateInterval(for: .today, now: afterDST) + + // THEN - Both should be exactly 24 hours + #expect(intervalBefore.duration == 86400) + #expect(intervalAfter.duration == 86400) + } + + // MARK: - Time Zone Tests + + @Test("Presets work correctly across different time zones", arguments: [ + (TimeZone(secondsFromGMT: 0)!, Date("2025-01-15T00:00:00Z")), + (TimeZone(secondsFromGMT: 6 * 3600)!, Date("2025-01-15T00:00:00+06:00")), + (TimeZone(secondsFromGMT: 12 * 3600)!, Date("2025-01-16T00:00:00+12:00")), + (TimeZone(secondsFromGMT: 18 * 3600)!, Date("2025-01-16T00:00:00+18:00")), + (TimeZone(secondsFromGMT: -6 * 3600)!, Date("2025-01-15T00:00:00-06:00")), + (TimeZone(secondsFromGMT: -12 * 3600)!, Date("2025-01-15T00:00:00-12:00")), + (TimeZone(secondsFromGMT: -18 * 3600)!, Date("2025-01-14T00:00:00-18:00")) + ]) + func presetsWithDifferentTimeZones(timeZone: TimeZone, expectedStart: Date) { + // GIVEN reporting time zone is differnet from your current time zone (UTC) + let calendar = Calendar.mock(timeZone: timeZone) + + // GIVEN it's 3 PM in UTC (your current time zone) + let now = Date("2025-01-15T15:00:00Z") + + // WHEN + let interval = calendar.makeDateInterval(for: .today, now: now) + + // THEN "today" is picked according to your site reporting time zone + // and not your local time zone + #expect(interval.start == expectedStart) + } + + // MARK: - Edge Cases for All Presets + + @Test("All presets handle month boundaries correctly", arguments: [ + (DateIntervalPreset.today, Date("2025-01-31T00:00:00-03:00"), Date("2025-02-01T00:00:00-03:00")), + (.thisWeek, Date("2025-01-26T00:00:00-03:00"), Date("2025-02-02T00:00:00-03:00")), + (.thisMonth, Date("2025-01-01T00:00:00-03:00"), Date("2025-02-01T00:00:00-03:00")), + (.thisQuarter, Date("2025-01-01T00:00:00-03:00"), Date("2025-04-01T00:00:00-03:00")), + (.thisYear, Date("2025-01-01T00:00:00-03:00"), Date("2026-01-01T00:00:00-03:00")), + (.last7Days, Date("2025-01-24T00:00:00-03:00"), Date("2025-01-31T00:00:00-03:00")), + (.last28Days, Date("2025-01-03T00:00:00-03:00"), Date("2025-01-31T00:00:00-03:00")), + (.last30Days, Date("2025-01-01T00:00:00-03:00"), Date("2025-01-31T00:00:00-03:00")), + (.last90Days, Date("2024-11-02T00:00:00-03:00"), Date("2025-01-31T00:00:00-03:00")), + (.last6Months, Date("2024-08-01T00:00:00-03:00"), Date("2025-02-01T00:00:00-03:00")), + (.last12Months, Date("2024-02-01T00:00:00-03:00"), Date("2025-02-01T00:00:00-03:00")), + (.last3Years, Date("2023-01-01T00:00:00-03:00"), Date("2026-01-01T00:00:00-03:00")), + (.last10Years, Date("2016-01-01T00:00:00-03:00"), Date("2026-01-01T00:00:00-03:00")) + ]) + func allPresetsMonthBoundaries(preset: DateIntervalPreset, expectedStart: Date, expectedEnd: Date) { + // GIVEN today is the last day of the month + let endOfMonth = Date("2025-01-31T14:30:00-03:00") + + // WHEN + let interval = calendar.makeDateInterval(for: preset, now: endOfMonth) + + // THEN + #expect(interval.start == expectedStart) + #expect(interval.end == expectedEnd) + } + + @Test("All presets handle year boundaries correctly", arguments: [ + (DateIntervalPreset.today, Date("2025-01-01T00:00:00-03:00"), Date("2025-01-02T00:00:00-03:00")), + (.thisWeek, Date("2024-12-29T00:00:00-03:00"), Date("2025-01-05T00:00:00-03:00")), + (.thisMonth, Date("2025-01-01T00:00:00-03:00"), Date("2025-02-01T00:00:00-03:00")), + (.thisQuarter, Date("2025-01-01T00:00:00-03:00"), Date("2025-04-01T00:00:00-03:00")), + (.thisYear, Date("2025-01-01T00:00:00-03:00"), Date("2026-01-01T00:00:00-03:00")), + (.last7Days, Date("2024-12-25T00:00:00-03:00"), Date("2025-01-01T00:00:00-03:00")), + (.last28Days, Date("2024-12-04T00:00:00-03:00"), Date("2025-01-01T00:00:00-03:00")), + (.last30Days, Date("2024-12-02T00:00:00-03:00"), Date("2025-01-01T00:00:00-03:00")), + (.last90Days, Date("2024-10-03T00:00:00-03:00"), Date("2025-01-01T00:00:00-03:00")), + (.last6Months, Date("2024-08-01T00:00:00-03:00"), Date("2025-02-01T00:00:00-03:00")), + (.last12Months, Date("2024-02-01T00:00:00-03:00"), Date("2025-02-01T00:00:00-03:00")), + (.last3Years, Date("2023-01-01T00:00:00-03:00"), Date("2026-01-01T00:00:00-03:00")), + (.last10Years, Date("2016-01-01T00:00:00-03:00"), Date("2026-01-01T00:00:00-03:00")) + ]) + func allPresetsYearBoundaries(preset: DateIntervalPreset, expectedStart: Date, expectedEnd: Date) { + // GIVEN today is the first day of the year + let startOfYear = Date("2025-01-01T14:30:00-03:00") + + // WHEN + let interval = calendar.makeDateInterval(for: preset, now: startOfYear) + + // THEN + #expect(interval.start == expectedStart) + #expect(interval.end == expectedEnd) + } + + // MARK: - Duration Tests + + @Test("DateInterval durations are calculated correctly") + func intervalDurations() { + // GIVEN + let now = Date("2025-01-15T14:30:00-03:00") + + // Single day + let day = calendar.makeDateInterval(for: .today, now: now) + #expect(abs(day.duration - 86400) < 2) // ~24 hours + + // Week + let week = calendar.makeDateInterval(for: .last7Days, now: now) + #expect(abs(week.duration - (86400 * 7)) < 8) // ~7 days + + // Month (30 days) + let month = calendar.makeDateInterval(for: .last30Days, now: now) + #expect(abs(month.duration - (86400 * 30)) < 31) // ~30 days + } + + // MARK: - Quarter Tests + + @Test("Quarter calculations work correctly", arguments: [ + // Q1: Jan-Mar + (Date("2025-01-15T14:30:00-03:00"), Date("2025-01-01T00:00:00-03:00"), Date("2025-04-01T00:00:00-03:00")), + (Date("2025-02-28T14:30:00-03:00"), Date("2025-01-01T00:00:00-03:00"), Date("2025-04-01T00:00:00-03:00")), + (Date("2025-03-31T14:30:00-03:00"), Date("2025-01-01T00:00:00-03:00"), Date("2025-04-01T00:00:00-03:00")), + // Q2: Apr-Jun + (Date("2025-04-01T14:30:00-03:00"), Date("2025-04-01T00:00:00-03:00"), Date("2025-07-01T00:00:00-03:00")), + (Date("2025-05-15T14:30:00-03:00"), Date("2025-04-01T00:00:00-03:00"), Date("2025-07-01T00:00:00-03:00")), + (Date("2025-06-30T14:30:00-03:00"), Date("2025-04-01T00:00:00-03:00"), Date("2025-07-01T00:00:00-03:00")), + // Q3: Jul-Sep + (Date("2025-07-01T14:30:00-03:00"), Date("2025-07-01T00:00:00-03:00"), Date("2025-10-01T00:00:00-03:00")), + (Date("2025-08-15T14:30:00-03:00"), Date("2025-07-01T00:00:00-03:00"), Date("2025-10-01T00:00:00-03:00")), + (Date("2025-09-30T14:30:00-03:00"), Date("2025-07-01T00:00:00-03:00"), Date("2025-10-01T00:00:00-03:00")), + // Q4: Oct-Dec + (Date("2025-10-01T14:30:00-03:00"), Date("2025-10-01T00:00:00-03:00"), Date("2026-01-01T00:00:00-03:00")), + (Date("2025-11-15T14:30:00-03:00"), Date("2025-10-01T00:00:00-03:00"), Date("2026-01-01T00:00:00-03:00")), + (Date("2025-12-31T14:30:00-03:00"), Date("2025-10-01T00:00:00-03:00"), Date("2026-01-01T00:00:00-03:00")) + ]) + func quarterCalculations(now: Date, expectedStart: Date, expectedEnd: Date) { + // WHEN + let interval = calendar.makeDateInterval(for: .thisQuarter, now: now) + + // THEN + #expect(interval.start == expectedStart) + #expect(interval.end == expectedEnd) + } + + // MARK: - Special Date Tests + + @Test("Presets at exact midnight") + func presetsAtMidnight() { + // GIVEN + let midnight = Date("2025-01-15T00:00:00-03:00") + + // WHEN + let interval = calendar.makeDateInterval(for: .today, now: midnight) + + // THEN + #expect(interval.start == Date("2025-01-15T00:00:00-03:00")) + #expect(interval.end == Date("2025-01-16T00:00:00-03:00")) + } + + @Test("Presets handle very old dates") + func presetsWithOldDates() { + // GIVEN - Date from year 2000 + let oldDate = Date("2000-06-15T14:30:00-03:00") + + // WHEN + let threeYears = calendar.makeDateInterval(for: .last3Years, now: oldDate) + + // THEN + #expect(threeYears.start == Date("1998-01-01T00:00:00-03:00")) + #expect(threeYears.end == Date("2001-01-01T00:00:00-03:00")) + } +} diff --git a/Modules/Tests/JetpackStatsTests/DataPointTests.swift b/Modules/Tests/JetpackStatsTests/DataPointTests.swift new file mode 100644 index 000000000000..720bed4869ce --- /dev/null +++ b/Modules/Tests/JetpackStatsTests/DataPointTests.swift @@ -0,0 +1,143 @@ +import Testing +import Foundation +@testable import JetpackStats + +@Suite +struct DataPointTests { + let calendar = Calendar.mock(timeZone: .eastern) + + @Test("Maps previous data to current period with simple day offset") + func testMapPreviousDataToCurrentSimpleDayOffset() { + // GIVEN + let previousStart = Date("2025-01-01T00:00:00-03:00") + let previousEnd = Date("2025-01-08T00:00:00-03:00") + let previousRange = DateInterval(start: previousStart, end: previousEnd) + + let currentStart = Date("2025-01-08T00:00:00-03:00") + let currentEnd = Date("2025-01-15T00:00:00-03:00") + let currentRange = DateInterval(start: currentStart, end: currentEnd) + + let previousData = [ + DataPoint(date: Date("2025-01-01T00:00:00-03:00"), value: 100), + DataPoint(date: Date("2025-01-02T00:00:00-03:00"), value: 200), + DataPoint(date: Date("2025-01-03T00:00:00-03:00"), value: 300), + DataPoint(date: Date("2025-01-04T00:00:00-03:00"), value: 400), + DataPoint(date: Date("2025-01-05T00:00:00-03:00"), value: 500), + DataPoint(date: Date("2025-01-06T00:00:00-03:00"), value: 600), + DataPoint(date: Date("2025-01-07T00:00:00-03:00"), value: 700) + ] + + // WHEN + let mappedData = DataPoint.mapDataPoints( + previousData, + from: previousRange, + to: currentRange, + component: .day, + calendar: calendar + ) + + // THEN + #expect(mappedData.count == 7) + #expect(mappedData[0].date == Date("2025-01-08T00:00:00-03:00")) + #expect(mappedData[0].value == 100) + #expect(mappedData[1].date == Date("2025-01-09T00:00:00-03:00")) + #expect(mappedData[1].value == 200) + #expect(mappedData[6].date == Date("2025-01-14T00:00:00-03:00")) + #expect(mappedData[6].value == 700) + } + + @Test("Maps previous month data to current month") + func testMapPreviousMonthDataToCurrent() { + // GIVEN + let previousStart = Date("2024-12-01T00:00:00-03:00") + let previousEnd = Date("2025-01-01T00:00:00-03:00") + let previousRange = DateInterval(start: previousStart, end: previousEnd) + + let currentStart = Date("2025-01-01T00:00:00-03:00") + let currentEnd = Date("2025-02-01T00:00:00-03:00") + let currentRange = DateInterval(start: currentStart, end: currentEnd) + + let previousData = [ + DataPoint(date: Date("2024-12-01T00:00:00-03:00"), value: 1000), + DataPoint(date: Date("2024-12-15T00:00:00-03:00"), value: 2000), + DataPoint(date: Date("2024-12-31T00:00:00-03:00"), value: 3000) + ] + + // WHEN + let mappedData = DataPoint.mapDataPoints( + previousData, + from: previousRange, + to: currentRange, + component: .month, + calendar: calendar + ) + + // THEN + #expect(mappedData.count == 3) + #expect(mappedData[0].date == Date("2025-01-01T00:00:00-03:00")) + #expect(mappedData[0].value == 1000) + #expect(mappedData[1].date == Date("2025-01-15T00:00:00-03:00")) + #expect(mappedData[1].value == 2000) + #expect(mappedData[2].date == Date("2025-01-31T00:00:00-03:00")) + #expect(mappedData[2].value == 3000) + } + + @Test("Maps with empty previous data") + func testMapEmptyPreviousData() { + // GIVEN + let previousRange = DateInterval( + start: Date("2025-01-01T00:00:00-03:00"), + end: Date("2025-01-08T00:00:00-03:00") + ) + let currentRange = DateInterval( + start: Date("2025-01-08T00:00:00-03:00"), + end: Date("2025-01-15T00:00:00-03:00") + ) + let previousData: [DataPoint] = [] + + // WHEN + let mappedData = DataPoint.mapDataPoints( + previousData, + from: previousRange, + to: currentRange, + component: .day, + calendar: calendar + ) + + // THEN + #expect(mappedData.isEmpty) + } + + @Test("Maps year-over-year comparison") + func testMapYearOverYearComparison() { + // GIVEN + let previousStart = Date("2024-01-01T00:00:00-03:00") + let previousEnd = Date("2024-01-08T00:00:00-03:00") + let previousRange = DateInterval(start: previousStart, end: previousEnd) + + let currentStart = Date("2025-01-01T00:00:00-03:00") + let currentEnd = Date("2025-01-08T00:00:00-03:00") + let currentRange = DateInterval(start: currentStart, end: currentEnd) + + let previousData = [ + DataPoint(date: Date("2024-01-01T00:00:00-03:00"), value: 1000), + DataPoint(date: Date("2024-01-07T00:00:00-03:00"), value: 2000) + ] + + // WHEN + let mappedData = DataPoint.mapDataPoints( + previousData, + from: previousRange, + to: currentRange, + component: .year, + calendar: calendar + ) + + // THEN + #expect(mappedData.count == 2) + #expect(mappedData[0].date == Date("2025-01-01T00:00:00-03:00")) + #expect(mappedData[0].value == 1000) + #expect(mappedData[1].date == Date("2025-01-07T00:00:00-03:00")) + #expect(mappedData[1].value == 2000) + } +} diff --git a/Modules/Tests/JetpackStatsTests/DateIntervalTests.swift b/Modules/Tests/JetpackStatsTests/DateIntervalTests.swift new file mode 100644 index 000000000000..c4c85daef3ef --- /dev/null +++ b/Modules/Tests/JetpackStatsTests/DateIntervalTests.swift @@ -0,0 +1,51 @@ +import Testing +import Foundation +@testable import JetpackStats + +@Suite +struct DateIntervalTests { + let calendar = Calendar.mock(timeZone: .eastern) + + @Test("DateInterval assumptions") + func assumptions() throws { + // GIVEN + let timeZone = try #require(TimeZone(secondsFromGMT: -10800)) // -3 hours + + var calendar = Calendar.current + calendar.timeZone = timeZone + + // WHEN + var interval = try #require(calendar.dateInterval(of: .day, for: Date("2025-01-15T14:30:00-03:00"))) + + // THEN the beginning is the beginnig of the given day + #expect(interval.start == Date("2025-01-15T00:00:00-03:00")) + + // THEN the end is the beginning of the next day + #expect(interval.end == Date("2025-01-16T00:00:00-03:00")) + + // THEN the interval technically contains the date with "2025-01-16" date + #expect(interval.contains(Date("2025-01-16T00:00:00-03:00"))) + + // GIVEN + let formatter = DateIntervalFormatter() + formatter.timeZone = timeZone + formatter.dateStyle = .short + formatter.timeStyle = .short + formatter.locale = Locale(identifier: "en_us") + + // THEN `DateIntervalFormatter` is fundamentally designed to represent time ranges + #expect(formatter.string(from: interval) == "1/15/25, 12:00 AM – 1/16/25, 12:00 AM") + + // WHEN + formatter.timeStyle = .none + + // THEN date without time may appear off + #expect(formatter.string(from: interval) == "1/15/25 – 1/16/25") + + // WHEN date is adjusted + interval.end = try #require(calendar.date(byAdding: .second, value: -1, to: interval.end)) + + // THEN the formatting matches what the user would expect + #expect(formatter.string(from: interval) == "1/15/25") + } +} diff --git a/Modules/Tests/JetpackStatsTests/DateRangeComparisonPeriodTests.swift b/Modules/Tests/JetpackStatsTests/DateRangeComparisonPeriodTests.swift new file mode 100644 index 000000000000..0010f41cfa45 --- /dev/null +++ b/Modules/Tests/JetpackStatsTests/DateRangeComparisonPeriodTests.swift @@ -0,0 +1,184 @@ +import Testing +import Foundation +@testable import JetpackStats + +@Suite +struct DateRangeComparisonPeriodTests { + let calendar = Calendar.mock(timeZone: TimeZone(secondsFromGMT: 0)!) + + // MARK: - DateRangeComparisonPeriod Tests + + @Test + func testPrecedingPeriodForDay() { + // GIVEN + let currentRange = DateInterval( + start: Date("2025-01-15T00:00:00Z"), + end: Date("2025-01-16T00:00:00Z") + ) + + // WHEN + let comparisonRange = calendar.comparisonRange(for: currentRange, period: .precedingPeriod, component: .day) + + // THEN + #expect(comparisonRange.start == Date("2025-01-14T00:00:00Z")) + #expect(comparisonRange.end == Date("2025-01-15T00:00:00Z")) + } + + @Test + func testPrecedingPeriodForWeek() { + // GIVEN + let currentRange = DateInterval( + start: Date("2025-01-12T00:00:00Z"), // Sunday + end: Date("2025-01-19T00:00:00Z") // Next Sunday + ) + + // WHEN + let comparisonRange = calendar.comparisonRange(for: currentRange, period: .precedingPeriod, component: .weekOfYear) + + // THEN + #expect(comparisonRange.start == Date("2025-01-05T00:00:00Z")) + #expect(comparisonRange.end == Date("2025-01-12T00:00:00Z")) + } + + @Test + func testPrecedingPeriodForMonth() { + // GIVEN + let currentRange = DateInterval( + start: Date("2025-02-01T00:00:00Z"), + end: Date("2025-03-01T00:00:00Z") + ) + + // WHEN + let comparisonRange = calendar.comparisonRange(for: currentRange, period: .precedingPeriod, component: .month) + + // THEN + #expect(comparisonRange.start == Date("2025-01-01T00:00:00Z")) + #expect(comparisonRange.end == Date("2025-02-01T00:00:00Z")) + } + + @Test + func testPrecedingPeriodForYear() { + // GIVEN + let currentRange = DateInterval( + start: Date("2025-01-01T00:00:00Z"), + end: Date("2026-01-01T00:00:00Z") + ) + + // WHEN + let comparisonRange = calendar.comparisonRange(for: currentRange, period: .precedingPeriod, component: .year) + + // THEN + #expect(comparisonRange.start == Date("2024-01-01T00:00:00Z")) + #expect(comparisonRange.end == Date("2025-01-01T00:00:00Z")) + } + + @Test + func testPrecedingPeriodForCustomRange() { + // GIVEN - 7 day custom range + let currentRange = DateInterval( + start: Date("2025-01-08T00:00:00Z"), + end: Date("2025-01-15T00:00:00Z") + ) + + // WHEN - Use nil component for custom ranges + let comparisonRange = calendar.comparisonRange(for: currentRange, period: .precedingPeriod, component: .day) + + // THEN - Should shift by duration (7 days) + #expect(comparisonRange.start == Date("2025-01-01T00:00:00Z")) + #expect(comparisonRange.end == Date("2025-01-08T00:00:00Z")) + } + + @Test + func testSamePeriodLastYearForDay() { + // GIVEN + let currentRange = DateInterval( + start: Date("2025-01-15T00:00:00Z"), + end: Date("2025-01-16T00:00:00Z") + ) + + // WHEN + let comparisonRange = calendar.comparisonRange(for: currentRange, period: .samePeriodLastYear, component: .day) + + // THEN + #expect(comparisonRange.start == Date("2024-01-15T00:00:00Z")) + #expect(comparisonRange.end == Date("2024-01-16T00:00:00Z")) + } + + @Test + func testSamePeriodLastYearForMonth() { + // GIVEN + let currentRange = DateInterval( + start: Date("2025-02-01T00:00:00Z"), + end: Date("2025-03-01T00:00:00Z") + ) + + // WHEN + let comparisonRange = calendar.comparisonRange(for: currentRange, period: .samePeriodLastYear, component: .month) + + // THEN + #expect(comparisonRange.start == Date("2024-02-01T00:00:00Z")) + #expect(comparisonRange.end == Date("2024-03-01T00:00:00Z")) + } + + @Test + func testSamePeriodLastYearForLeapYear() { + // GIVEN - February 29, 2024 (leap year) + let currentRange = DateInterval( + start: Date("2024-02-29T00:00:00Z"), + end: Date("2024-03-01T00:00:00Z") + ) + + // WHEN + let comparisonRange = calendar.comparisonRange(for: currentRange, period: .samePeriodLastYear, component: .day) + + // THEN - Should handle leap year correctly + #expect(comparisonRange.start == Date("2023-02-28T00:00:00Z")) + #expect(comparisonRange.end == Date("2023-03-01T00:00:00Z")) + } + + // MARK: - StatsDateRange Integration Tests + + @Test + func testStatsDateRangeWithComparisonType() { + // GIVEN + let currentRange = DateInterval( + start: Date("2025-01-15T00:00:00Z"), + end: Date("2025-01-16T00:00:00Z") + ) + + // WHEN + let dateRange = StatsDateRange( + interval: currentRange, + component: .day, + comparison: .precedingPeriod, + calendar: calendar + ) + + // THEN + #expect(dateRange.comparison == DateRangeComparisonPeriod.precedingPeriod) + #expect(dateRange.effectiveComparisonInterval.start == Date("2025-01-14T00:00:00Z")) + #expect(dateRange.effectiveComparisonInterval.end == Date("2025-01-15T00:00:00Z")) + } + + @Test + func testNavigationPreservesComparisonType() { + // GIVEN + let initialRange = StatsDateRange( + interval: DateInterval( + start: Date("2025-01-15T00:00:00Z"), + end: Date("2025-01-16T00:00:00Z") + ), + component: .day, + comparison: .samePeriodLastYear, + calendar: calendar + ) + + // WHEN + let nextRange = initialRange.navigate(.forward) + + // THEN - Comparison type should be preserved + #expect(nextRange.comparison == .samePeriodLastYear) + #expect(nextRange.dateInterval.start == Date("2025-01-16T00:00:00Z")) + #expect(nextRange.effectiveComparisonInterval.start == Date("2024-01-16T00:00:00Z")) + } +} diff --git a/Modules/Tests/JetpackStatsTests/DateRangeGranularityTests.swift b/Modules/Tests/JetpackStatsTests/DateRangeGranularityTests.swift new file mode 100644 index 000000000000..d43fe2648026 --- /dev/null +++ b/Modules/Tests/JetpackStatsTests/DateRangeGranularityTests.swift @@ -0,0 +1,174 @@ +import Testing +import Foundation +@testable import JetpackStats + +@Suite +struct DateRangeGranularityTests { + let calendar = Calendar.mock(timeZone: .eastern) + + @Test("Determined period for 1 day or less returns hour granularity") + func granularityForLessThanOneDay() { + // Same day (1 day exclusive upper bound) + let singleDay = DateInterval( + start: Date("2024-01-01T00:00:00-03:00"), + end: Date("2024-01-02T00:00:00-03:00") + ) + #expect(singleDay.preferredGranularity == .hour) + } + + @Test("Determined period for 2+ days returns day granularity") + func granularityForDays() { + // 2 days + let twoDays = DateInterval( + start: Date("2024-01-01T00:00:00-03:00"), + end: Date("2024-01-03T00:00:00-03:00") + ) + #expect(twoDays.preferredGranularity == .day) + + // 3 days + let threeDays = DateInterval( + start: Date("2024-01-01T00:00:00-03:00"), + end: Date("2024-01-04T00:00:00-03:00") + ) + #expect(threeDays.preferredGranularity == .day) + + // 7 days + let sevenDays = DateInterval( + start: Date("2024-01-01T00:00:00-03:00"), + end: Date("2024-01-08T00:00:00-03:00") + ) + #expect(sevenDays.preferredGranularity == .day) + + // 15 days + let fifteenDays = DateInterval( + start: Date("2024-01-01T00:00:00-03:00"), + end: Date("2024-01-16T00:00:00-03:00") + ) + #expect(fifteenDays.preferredGranularity == .day) + + // 31 days (full month) + let fullMonth = DateInterval( + start: Date("2024-01-01T00:00:00-03:00"), + end: Date("2024-02-01T00:00:00-03:00") + ) + #expect(fullMonth.preferredGranularity == .day) + + // 90 days + let ninetyDays = DateInterval( + start: Date("2024-01-01T00:00:00-03:00"), + end: Date("2024-03-31T00:00:00-03:00") + ) + #expect(ninetyDays.preferredGranularity == .day) + } + + @Test("Determined period for 91+ days returns month granularity") + func granularityForMonths() { + // 91 days + let ninetyOneDays = DateInterval( + start: Date("2024-01-01T00:00:00-03:00"), + end: Date("2024-04-01T00:00:00-03:00") + ) + #expect(ninetyOneDays.preferredGranularity == .month) + + // ~181 days (6 months) + let sixMonths = DateInterval( + start: Date("2024-01-01T00:00:00-03:00"), + end: Date("2024-07-01T00:00:00-03:00") + ) + #expect(sixMonths.preferredGranularity == .month) + + // 366 days (leap year) + let leapYear = DateInterval( + start: Date("2024-01-01T00:00:00-03:00"), + end: Date("2025-01-01T00:00:00-03:00") + ) + #expect(leapYear.preferredGranularity == .month) + + // 730 days (2 years) + let twoYears = DateInterval( + start: Date("2024-01-01T00:00:00-03:00"), + end: Date("2025-12-31T00:00:00-03:00") + ) + #expect(twoYears.preferredGranularity == .month) + } + + @Test("Determined period for 25+ months returns year granularity") + func granularityForYears() { + // 25 months (just over 2 years) + let twentyFiveMonths = DateInterval( + start: Date("2024-01-01T00:00:00-03:00"), + end: Date("2026-02-02T00:00:00-03:00") + ) + #expect(twentyFiveMonths.preferredGranularity == .month) + + // 3 years + let threeYears = DateInterval( + start: Date("2024-01-01T00:00:00-03:00"), + end: Date("2027-01-02T00:00:00-03:00") + ) + #expect(threeYears.preferredGranularity == .month) + + // 5 years + let fiveYears = DateInterval( + start: Date("2024-01-01T00:00:00-03:00"), + end: Date("2029-01-02T00:00:00-03:00") + ) + #expect(fiveYears.preferredGranularity == .year) + } + + @Test("Granularity respects transitions at 14 and 90 day boundaries") + func granularityBoundaryTransitions() { + // Single day - should be hour + let singleDay = DateInterval( + start: Date("2024-01-01T00:00:00-03:00"), + end: Date("2024-01-02T00:00:00-03:00") + ) + #expect(singleDay.preferredGranularity == .hour) + + // 14 days - should be day + let fourteenDays = DateInterval( + start: Date("2024-01-01T00:00:00-03:00"), + end: Date("2024-01-15T00:00:00-03:00") + ) + #expect(fourteenDays.preferredGranularity == .day) + + // 15 days - should be day + let fifteenDays = DateInterval( + start: Date("2024-01-01T00:00:00-03:00"), + end: Date("2024-01-16T00:00:00-03:00") + ) + #expect(fifteenDays.preferredGranularity == .day) + + // 90 days - should be day + let ninetyDays = DateInterval( + start: Date("2024-01-01T00:00:00-03:00"), + end: Date("2024-03-31T00:00:00-03:00") + ) + #expect(ninetyDays.preferredGranularity == .day) + + // 91 days - should be month + let ninetyOneDays = DateInterval( + start: Date("2024-01-01T00:00:00-03:00"), + end: Date("2024-04-01T00:00:00-03:00") + ) + #expect(ninetyOneDays.preferredGranularity == .month) + } + + @Test("Granularity for preset ranges matches expected values") + func granularityForPresets() { + // Today - should be hour (single day) + #expect(calendar.makeDateInterval(for: .today).preferredGranularity == .hour) + + // Yesterday - should be hour (single day) + #expect(calendar.makeDateInterval(for: .today).preferredGranularity == .hour) + + // Last 7 days - should be day + #expect(calendar.makeDateInterval(for: .last7Days).preferredGranularity == .day) + + // Last 30 days - should be day + #expect(calendar.makeDateInterval(for: .last30Days).preferredGranularity == .day) + + // Last 12 months - should be month + #expect(calendar.makeDateInterval(for: .last12Months).preferredGranularity == .month) + } +} diff --git a/Modules/Tests/JetpackStatsTests/MockStatsServiceTests.swift b/Modules/Tests/JetpackStatsTests/MockStatsServiceTests.swift new file mode 100644 index 000000000000..c2bab0f40d8b --- /dev/null +++ b/Modules/Tests/JetpackStatsTests/MockStatsServiceTests.swift @@ -0,0 +1,56 @@ +import Testing +import Foundation +@testable import JetpackStats + +@Suite +struct MockStatsServiceTests { + let calendar = Calendar.mock(timeZone: .eastern) + + @Test("getTopListData returns valid data for posts") + func testGetTopListDataPosts() async throws { + // GIVEN + let service = MockStatsService(timeZone: .current) + let dateInterval = calendar.makeDateInterval(for: .today) + + // WHEN + let response = try await service.getTopListData( + .postsAndPages, + metric: .views, + interval: dateInterval, + granularity: dateInterval.preferredGranularity, + limit: nil + ) + + // THEN + #expect(response.items.count > 0) + #expect(response.items.count <= 40, "Should return maximum 40 items") + + // THEN all items are posts + for item in response.items { + if let post = item as? TopListItem.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..eb2c2c6ade4d --- /dev/null +++ b/Modules/Tests/JetpackStatsTests/StatsDataAggregationTests.swift @@ -0,0 +1,421 @@ +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 = [ + 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, 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] == 450) // 100 + 200 + 150 + + // Check hour 15:00 + let hour15 = Date("2025-01-15T15:00:00Z") + #expect(aggregated[hour15] == 300) + } + + @Test + func dailyAggregation() { + let aggregator = StatsDataAggregator(calendar: calendar) + + // Create test data across multiple days + 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, 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] == 450) + #expect(aggregated[day2] == 300) + } + + @Test + func monthlyAggregation() { + let aggregator = StatsDataAggregator(calendar: calendar) + + 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, 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] == 300) + #expect(aggregated[feb] == 300) + } + + @Test + func yearlyAggregation() { + let aggregator = StatsDataAggregator(calendar: calendar) + + 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, metric: .views) + + // Year granularity aggregates by month + #expect(aggregated.count == 1) + + let jan = Date("2025-01-01T00:00:00Z") + + #expect(aggregated[jan] == 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: - Averaged Metrics Tests + + @Test + func aggregateWithAveragedMetric() { + let aggregator = StatsDataAggregator(calendar: calendar) + + 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) + ] + + // Test with timeOnSite metric which uses average strategy + let aggregated = aggregator.aggregate(testData, granularity: .day, metric: .timeOnSite) + + #expect(aggregated.count == 2) + + let day1 = Date("2025-01-15T00:00:00Z") + let day2 = Date("2025-01-16T00:00:00Z") + + // 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 + + @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) + } +} diff --git a/Modules/Tests/JetpackStatsTests/StatsDateFormatterTests.swift b/Modules/Tests/JetpackStatsTests/StatsDateFormatterTests.swift new file mode 100644 index 000000000000..75ccbd06ef05 --- /dev/null +++ b/Modules/Tests/JetpackStatsTests/StatsDateFormatterTests.swift @@ -0,0 +1,71 @@ +import Testing +import Foundation +@testable import JetpackStats + +@Suite +struct StatsDateFormatterTests { + let formatter = StatsDateFormatter( + locale: Locale(identifier: "en_us"), + timeZone: .eastern + ) + + @Test func hourFormattingCompact() { + 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 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 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 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/Modules/Tests/JetpackStatsTests/StatsDateRangeFormatterTests.swift b/Modules/Tests/JetpackStatsTests/StatsDateRangeFormatterTests.swift new file mode 100644 index 000000000000..f940e5455c36 --- /dev/null +++ b/Modules/Tests/JetpackStatsTests/StatsDateRangeFormatterTests.swift @@ -0,0 +1,229 @@ +import Testing +import Foundation +@testable import JetpackStats + +@Suite +struct StatsDateRangeFormatterTests { + let calendar = Calendar.mock(timeZone: .eastern) + let locale = Locale(identifier: "en_US") + let now = Date("2025-07-15T10:00:00-03:00") + + // 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, now: now) == "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..1015f4a41078 --- /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: Decimal) { + // 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/Modules/Tests/JetpackStatsTests/WeeklyTrendsViewModelTests.swift b/Modules/Tests/JetpackStatsTests/WeeklyTrendsViewModelTests.swift new file mode 100644 index 000000000000..0b560159e68e --- /dev/null +++ b/Modules/Tests/JetpackStatsTests/WeeklyTrendsViewModelTests.swift @@ -0,0 +1,381 @@ +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..(for period: StatsPeriodUnit, - unit: StatsPeriodUnit?, - endingOn: Date, - limit: Int = 10, - completion: @escaping ((TimeStatsType?, Error?) -> Void)) { + override func getData( + for period: StatsPeriodUnit, + unit: StatsPeriodUnit? = nil, + startDate: Date? = nil, + endingOn: Date, + limit: Int = 10, + summarize: Bool? = nil, + parameters: [String: String]? = nil, + completion: @escaping (TimeStatsType?, (any Error)?) -> Void + ) where TimeStatsType: StatsTimeIntervalData { let mockType = TimeStatsType(date: endingOn, period: period, unit: unit, diff --git a/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent+JetpackStats.swift b/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent+JetpackStats.swift new file mode 100644 index 000000000000..4e56015340d5 --- /dev/null +++ b/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent+JetpackStats.swift @@ -0,0 +1,65 @@ +import Foundation +import JetpackStats + +extension StatsEvent { + /// Maps JetpackStats events to WordPress analytics events + var wpEvent: WPAnalyticsEvent { + switch self { + // Screen View Events + case .statsMainScreenShown: + return .jetpackStatsMainScreenShown + case .trafficTabShown: + return .jetpackStatsTrafficTabShown + case .realtimeTabShown: + return .jetpackStatsRealtimeTabShown + case .subscribersTabShown: + return .jetpackStatsSubscribersTabShown + case .postDetailsScreenShown: + return .jetpackStatsPostDetailsScreenShown + case .authorStatsScreenShown: + return .jetpackStatsAuthorStatsScreenShown + case .archiveStatsScreenShown: + return .jetpackStatsArchiveStatsScreenShown + case .externalLinkStatsScreenShown: + return .jetpackStatsExternalLinkStatsScreenShown + case .referrerStatsScreenShown: + return .jetpackStatsReferrerStatsScreenShown + + // Date Range Events + case .dateRangePresetSelected: + return .jetpackStatsDateRangePresetSelected + case .customDateRangeSelected: + return .jetpackStatsCustomDateRangeSelected + + // Card Events + case .cardShown: + return .jetpackStatsCardShown + case .cardAdded: + return .jetpackStatsCardAdded + case .cardRemoved: + return .jetpackStatsCardRemoved + + // Chart Events + case .chartTypeChanged: + return .jetpackStatsChartTypeChanged + case .chartMetricSelected: + return .jetpackStatsChartMetricSelected + + // List Events + case .topListItemTapped: + return .jetpackStatsTopListItemTapped + + // Navigation Events + case .statsTabSelected: + return .jetpackStatsTabSelected + + // Error Events + case .errorEncountered: + return .jetpackStatsErrorEncountered + } + } +} + +extension WPAnalyticsEvent { + static let isNewStatsKey = "new_stats" +} diff --git a/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift b/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift index 27f13b609af5..5cda1836bf10 100644 --- a/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift +++ b/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift @@ -609,6 +609,10 @@ import WordPressShared case statsEmailsViewMoreTapped case statsSubscribersChartTapped + // New Stats + case statsNewStatsEnabled + case statsNewStatsDisabled + // In-App Updates case inAppUpdateShown case inAppUpdateDismissed @@ -619,6 +623,42 @@ import WordPressShared case wpcomWebSignIn + // MARK: - Jetpack Stats + + // Screen View Events + case jetpackStatsMainScreenShown + case jetpackStatsTrafficTabShown + case jetpackStatsRealtimeTabShown + case jetpackStatsSubscribersTabShown + case jetpackStatsPostDetailsScreenShown + case jetpackStatsAuthorStatsScreenShown + case jetpackStatsArchiveStatsScreenShown + case jetpackStatsExternalLinkStatsScreenShown + case jetpackStatsReferrerStatsScreenShown + + // Date Range Events + case jetpackStatsDateRangePresetSelected + case jetpackStatsCustomDateRangeSelected + + // Card Events + case jetpackStatsCardShown + case jetpackStatsCardAdded + case jetpackStatsCardRemoved + case jetpackStatsCardEditMenuOpened + + // Chart Events + case jetpackStatsChartTypeChanged + case jetpackStatsChartMetricSelected + + // List Events + case jetpackStatsTopListItemTapped + + // Navigation Events + case jetpackStatsTabSelected + + // Error Events + case jetpackStatsErrorEncountered + /// A String that represents the event var value: String { switch self { @@ -1664,6 +1704,12 @@ import WordPressShared case .statsSubscribersChartTapped: return "stats_subscribers_chart_tapped" + // New Stats + case .statsNewStatsEnabled: + return "stats_new_stats_enabled" + case .statsNewStatsDisabled: + return "stats_new_stats_disabled" + // In-App Updates case .inAppUpdateShown: return "in_app_update_shown" @@ -1678,6 +1724,62 @@ import WordPressShared case .wpcomWebSignIn: return "wpcom_web_sign_in" + + // MARK: - Jetpack Stats + + // Screen View Events + case .jetpackStatsMainScreenShown: + return "jetpack_stats_main_screen_shown" + case .jetpackStatsTrafficTabShown: + return "jetpack_stats_traffic_tab_shown" + case .jetpackStatsRealtimeTabShown: + return "jetpack_stats_realtime_tab_shown" + case .jetpackStatsSubscribersTabShown: + return "jetpack_stats_subscribers_tab_shown" + case .jetpackStatsPostDetailsScreenShown: + return "jetpack_stats_post_details_screen_shown" + case .jetpackStatsAuthorStatsScreenShown: + return "jetpack_stats_author_stats_screen_shown" + case .jetpackStatsArchiveStatsScreenShown: + return "jetpack_stats_archive_stats_screen_shown" + case .jetpackStatsExternalLinkStatsScreenShown: + return "jetpack_stats_external_link_stats_screen_shown" + case .jetpackStatsReferrerStatsScreenShown: + return "jetpack_stats_referrer_stats_screen_shown" + + // Date Range Events + case .jetpackStatsDateRangePresetSelected: + return "jetpack_stats_date_range_preset_selected" + case .jetpackStatsCustomDateRangeSelected: + return "jetpack_stats_custom_date_range_selected" + + // Card Events + case .jetpackStatsCardShown: + return "jetpack_stats_card_shown" + case .jetpackStatsCardAdded: + return "jetpack_stats_card_added" + case .jetpackStatsCardRemoved: + return "jetpack_stats_card_removed" + case .jetpackStatsCardEditMenuOpened: + return "jetpack_stats_card_edit_menu_opened" + + // Chart Events + case .jetpackStatsChartTypeChanged: + return "jetpack_stats_chart_type_changed" + case .jetpackStatsChartMetricSelected: + return "jetpack_stats_chart_metric_selected" + + // List Events + case .jetpackStatsTopListItemTapped: + return "jetpack_stats_top_list_item_tapped" + + // Navigation Events + case .jetpackStatsTabSelected: + return "jetpack_stats_tab_selected" + + // Error Events + case .jetpackStatsErrorEncountered: + return "jetpack_stats_error_encountered" } // END OF SWITCH } diff --git a/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift b/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift index b0621431d471..d10a954ffc65 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 false } } @@ -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..31c6e0c7a317 100644 --- a/WordPress/Classes/Utility/ContentCoordinator.swift +++ b/WordPress/Classes/Utility/ContentCoordinator.swift @@ -65,9 +65,8 @@ struct DefaultContentCoordinator: ContentCoordinator { setTimePeriodForStatsURLIfPossible(url) } - 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..402a91fa9627 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 @@ -121,7 +122,14 @@ final class DashboardQuickActionsCardCell: UICollectionViewCell, Reusable, UITab } private func trackQuickActionsEvent(_ event: WPAnalyticsStat, blog: Blog) { - WPAppAnalytics.track(event, properties: [WPAppAnalyticsKeyTabSource: "dashboard", WPAppAnalyticsKeyTapSource: "quick_actions"], blog: blog) + var properties: [String: Any] = [ + WPAppAnalyticsKeyTabSource: "dashboard", + WPAppAnalyticsKeyTapSource: "quick_actions" + ] + if event == .statsAccessed, FeatureFlag.newStats.enabled { + properties[WPAnalyticsEvent.isNewStatsKey] = "1" + } + WPAppAnalytics.track(event, properties: properties, blog: blog) } } diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift index 4e26a3bf9b71..938596eb3683 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift @@ -265,12 +265,7 @@ extension BlogDetailsViewController { guard JetpackFeaturesRemovalCoordinator.jetpackFeaturesEnabled() else { return MovedToJetpackViewController(source: .stats) } - - let statsVC = StatsViewController() - statsVC.blog = blog - statsVC.hidesBottomBarWhenPushed = true - statsVC.navigationItem.largeTitleDisplayMode = .never - return statsVC + return StatsHostingViewController.makeStatsViewController(for: blog) } @objc(showDomainsFromSource:) @@ -355,10 +350,14 @@ extension BlogDetailsViewController { extension BlogDetailsViewController { @objc public func trackEvent(_ event: WPAnalyticsStat, from source: BlogDetailsNavigationSource) { - WPAppAnalytics.track(event, properties: [ + var properties: [String: Any] = [ WPAppAnalyticsKeyTapSource: source.string, WPAppAnalyticsKeyTabSource: "site_menu" - ], blog: blog) + ] + if event == .statsAccessed, FeatureFlag.newStats.enabled { + properties[WPAnalyticsEvent.isNewStatsKey] = "1" + } + WPAppAnalytics.track(event, properties: properties, blog: blog) } } 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/Me/App Settings/ExperimentalFeaturesDataProvider.swift b/WordPress/Classes/ViewRelated/Me/App Settings/ExperimentalFeaturesDataProvider.swift index bb22f5b4509f..1d8bfaaa573d 100644 --- a/WordPress/Classes/ViewRelated/Me/App Settings/ExperimentalFeaturesDataProvider.swift +++ b/WordPress/Classes/ViewRelated/Me/App Settings/ExperimentalFeaturesDataProvider.swift @@ -9,6 +9,7 @@ class ExperimentalFeaturesDataProvider: ExperimentalFeaturesViewModel.DataProvid FeatureFlag.allowApplicationPasswords, RemoteFeatureFlag.newGutenberg, FeatureFlag.newGutenbergThemeStyles, + FeatureFlag.newStats, ] private let flagStore = FeatureFlagOverrideStore() diff --git a/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationContentRouter.swift b/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationContentRouter.swift index 615a536bcdab..b6749ff15cdb 100644 --- a/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationContentRouter.swift +++ b/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationContentRouter.swift @@ -116,7 +116,10 @@ struct NotificationContentRouter { } private func trackStatsRoute() { - let properties: [AnyHashable: Any] = [WPAppAnalyticsKeyTapSource: "notification"] + var properties: [AnyHashable: Any] = [WPAppAnalyticsKeyTapSource: "notification"] + if FeatureFlag.newStats.enabled { + properties[WPAnalyticsEvent.isNewStatsKey] = "1" + } WPAppAnalytics.track(.statsAccessed, withProperties: properties) } } 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/Charts/Charts+Support.swift b/WordPress/Classes/ViewRelated/Stats/Charts/Charts+Support.swift index 2b83b908098e..32447ee2f7c7 100644 --- a/WordPress/Classes/ViewRelated/Stats/Charts/Charts+Support.swift +++ b/WordPress/Classes/ViewRelated/Stats/Charts/Charts+Support.swift @@ -1,6 +1,7 @@ import UIKit import DGCharts import WordPressKit +import WordPressShared // MARK: - Charts extensions @@ -116,6 +117,9 @@ enum LineChartAnalyticsPropertyGranularityValue: String, CaseIterable { extension StatsPeriodUnit { var analyticsGranularity: BarChartAnalyticsPropertyGranularityValue { switch self { + case .hour: + wpAssertionFailure("StatsPeriodHelper.hour period is unsupported in the legacy stats") + return .days case .day: return .days case .week: @@ -129,6 +133,9 @@ extension StatsPeriodUnit { var analyticsGranularityLine: LineChartAnalyticsPropertyGranularityValue { switch self { + case .hour: + wpAssertionFailure("StatsPeriodHelper.hour period is unsupported in the legacy stats") + return .days 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..748f671ce04b 100644 --- a/WordPress/Classes/ViewRelated/Stats/Helpers/StatsDataHelper.swift +++ b/WordPress/Classes/ViewRelated/Stats/Helpers/StatsDataHelper.swift @@ -247,6 +247,9 @@ extension StatsPeriodUnit { var dateFormatTemplate: String { switch self { + case .hour: + wpAssertionFailure("StatsPeriodHelper.hour period is unsupported in the legacy stats") + return "MMM d, yyyy" case .day: return "MMM d, yyyy" case .week: @@ -260,6 +263,8 @@ extension StatsPeriodUnit { var calendarComponent: Calendar.Component { switch self { + case .hour: + return .hour case .day: return .day case .week: @@ -273,6 +278,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..29d523b285bd 100644 --- a/WordPress/Classes/ViewRelated/Stats/Helpers/StatsPeriodHelper.swift +++ b/WordPress/Classes/ViewRelated/Stats/Helpers/StatsPeriodHelper.swift @@ -1,5 +1,6 @@ import Foundation import WordPressKit +import WordPressShared class StatsPeriodHelper { private lazy var calendar: Calendar = { @@ -20,6 +21,9 @@ class StatsPeriodHelper { oldestDate = oldestDate.normalizedDate() switch period { + case .hour: + wpAssertionFailure("StatsPeriodHelper.hour period is unsupported in the legacy stats") + return date > oldestDate case .day: return date > oldestDate case .week: @@ -47,6 +51,9 @@ class StatsPeriodHelper { let date = dateIn.normalizedDate() switch period { + case .hour: + wpAssertionFailure("StatsPeriodHelper.hour period is unsupported in the legacy stats") + return date < currentDate.normalizedDate() case .day: return date < currentDate.normalizedDate() case .week: @@ -70,6 +77,9 @@ class StatsPeriodHelper { func endDate(from intervalStartDate: Date, period: StatsPeriodUnit) -> Date { switch period { + case .hour: + wpAssertionFailure("StatsPeriodHelper.hour period is unsupported in the legacy stats") + return intervalStartDate.normalizedDate() case .day: return intervalStartDate.normalizedDate() case .week: @@ -103,6 +113,10 @@ class StatsPeriodHelper { } switch unit { + case .hour: + wpAssertionFailure("StatsPeriodHelper.hour period is unsupported in the legacy stats") + return adjustedDate.normalizedDate() + case .day: return adjustedDate.normalizedDate() 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/Shared Views/Date Chooser/SiteStatsTableHeaderView.swift b/WordPress/Classes/ViewRelated/Stats/Shared Views/Date Chooser/SiteStatsTableHeaderView.swift index 2472f7050162..e5ecd9694e8b 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,9 @@ private extension SiteStatsTableHeaderView { dateFormatter.setLocalizedDateFormatFromTemplate(period.dateFormatTemplate) switch period { + case .hour: + wpAssertionFailure("StatsPeriodHelper.hour period is unsupported in the legacy stats") + return (dateFormatter.string(from: date), nil) case .day, .month, .year: return (dateFormatter.string(from: date), nil) case .week: diff --git a/WordPress/Classes/ViewRelated/Stats/Shared Views/Post Stats/PostStatsTableViewController.swift b/WordPress/Classes/ViewRelated/Stats/Shared Views/Post Stats/PostStatsTableViewController.swift index 28d345daa3b0..9b3d05ff52b1 100644 --- a/WordPress/Classes/ViewRelated/Stats/Shared Views/Post Stats/PostStatsTableViewController.swift +++ b/WordPress/Classes/ViewRelated/Stats/Shared Views/Post Stats/PostStatsTableViewController.swift @@ -136,6 +136,10 @@ private extension PostStatsTableViewController { properties["post_id"] = postIdentifier } + if FeatureFlag.newStats.enabled { + properties[WPAnalyticsEvent.isNewStatsKey] = "1" + } + WPAppAnalytics.track(.statsAccessed, withProperties: properties) } diff --git a/WordPress/Classes/ViewRelated/Stats/SiteStatsDashboardViewController.swift b/WordPress/Classes/ViewRelated/Stats/SiteStatsDashboardViewController.swift index f53b326f6673..e5c2bd79db56 100644 --- a/WordPress/Classes/ViewRelated/Stats/SiteStatsDashboardViewController.swift +++ b/WordPress/Classes/ViewRelated/Stats/SiteStatsDashboardViewController.swift @@ -1,6 +1,10 @@ import UIKit import WordPressKit import WordPressShared +import WordPressData +import Combine +import TipKit +import BuildSettingsKit enum StatsTabType: Int, FilterTabBarItem, CaseIterable { case insights = 0 @@ -54,6 +58,9 @@ public class SiteStatsDashboardViewController: UIViewController { private var pageViewController: UIPageViewController? private lazy var displayedTabs: [StatsTabType] = StatsTabType.displayedTabs + private var tipObserver: TipObserver? + private var isUsingMockData = false + private var navigationItemObserver: NSKeyValueObservation? @objc public lazy var manageInsightsButton: UIBarButtonItem = { let button = UIBarButtonItem( @@ -65,6 +72,14 @@ public class SiteStatsDashboardViewController: UIViewController { return button }() + private lazy var statsMenuButton: UIBarButtonItem = { + let button = UIBarButtonItem( + image: UIImage(systemName: "ellipsis"), + menu: createStatsMenu() + ) + return button + }() + // MARK: - Stats View Controllers private lazy var insightsTableViewController = { @@ -74,7 +89,29 @@ public class SiteStatsDashboardViewController: UIViewController { return viewController }() - private lazy var trafficTableViewController = { + private lazy var trafficTableViewController: UIViewController = { + // If new stats is enabled, show StatsHostingViewController instead + if FeatureFlag.newStats.enabled { + return createNewTrafficViewController() ?? createClassicTrafficViewController() + } else { + return createClassicTrafficViewController() + } + }() + + private func createNewTrafficViewController() -> UIViewController? { + if isUsingMockData { + // Create with demo context for mock data + return StatsHostingViewController.makeNewTrafficViewController(blog: nil, parentViewController: self, isDemo: true) + } else { + guard let siteID = SiteStatsInformation.sharedInstance.siteID, + let blog = Blog.lookup(withID: siteID, in: ContextManager.shared.mainContext) else { + return nil + } + return StatsHostingViewController.makeNewTrafficViewController(blog: blog, parentViewController: self, isDemo: false) + } + } + + private func createClassicTrafficViewController() -> UIViewController { let date: Date if let selectedDate = SiteStatsDashboardPreferences.getLastSelectedDateFromUserDefaults() { date = selectedDate @@ -87,7 +124,7 @@ public class SiteStatsDashboardViewController: UIViewController { let viewController = SiteStatsPeriodTableViewController(date: date, period: currentPeriod) viewController.bannerView = jetpackBannerView return viewController - }() + } private lazy var subscribersViewController = { let viewModel = StatsSubscribersViewModel() @@ -96,6 +133,10 @@ public class SiteStatsDashboardViewController: UIViewController { // MARK: - View + deinit { + navigationItemObserver?.invalidate() + } + public override func viewDidLoad() { super.viewDidLoad() @@ -117,7 +158,40 @@ public class SiteStatsDashboardViewController: UIViewController { } func configureNavBar() { - parent?.navigationItem.rightBarButtonItem = currentSelectedTab == .insights ? manageInsightsButton : nil + // Clean up previous observer + navigationItemObserver?.invalidate() + navigationItemObserver = nil + + switch currentSelectedTab { + case .insights: + parent?.navigationItem.rightBarButtonItem = manageInsightsButton + case .traffic: + // Always show the menu for switching between stats experiences + statsMenuButton.menu = createStatsMenu() + + // Set up observer for navigation item changes + navigationItemObserver = trafficTableViewController.navigationItem.observe(\.trailingItemGroups, options: [.initial, .new]) { [weak self] navigationItem, _ in + guard let self else { return } + DispatchQueue.main.async { + self.updateParentNavigationItems(with: self.trafficTableViewController) + } + } + + // Show tip for new stats if available and not enabled + if #available(iOS 17, *), !FeatureFlag.newStats.enabled { + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) { + self.showNewStatsTip() + } + } + default: + parent?.navigationItem.rightBarButtonItem = nil + } + } + + private func updateParentNavigationItems(with childVC: UIViewController) { + parent?.navigationItem.trailingItemGroups = childVC.navigationItem.trailingItemGroups + [ + UIBarButtonItemGroup.fixedGroup(items: [statsMenuButton]) + ] } func configureJetpackBanner() { @@ -136,6 +210,127 @@ public class SiteStatsDashboardViewController: UIViewController { insightsTableViewController.showAddInsightView(source: "nav_bar") } + private func createStatsMenu() -> UIMenu { + var menuElements: [UIMenuElement] = [] + + if FeatureFlag.newStats.enabled { + // Main actions + var mainActions: [UIMenuElement] = [] + + // Add "Switch to Classic Stats" option when new stats is enabled + let switchToClassicAction = UIAction( + title: Strings.switchToClassic, + image: UIImage(systemName: "arrow.uturn.backward") + ) { [weak self] _ in + self?.disableNewStats() + } + mainActions.append(switchToClassicAction) + + // Add "Send Feedback" option + let sendFeedbackAction = UIAction( + title: Strings.sendFeedback, + image: UIImage(systemName: "envelope") + ) { [weak self] _ in + self?.showFeedbackView() + } + mainActions.append(sendFeedbackAction) + + menuElements.append(contentsOf: mainActions) + + // Debug section (only in debug builds) + if BuildConfiguration.current == .debug { + let toggleDataSource = UIAction( + title: isUsingMockData ? "Use Real Data" : "Use Mock Data", + image: UIImage(systemName: "arrow.triangle.2.circlepath") + ) { [weak self] _ in + self?.toggleDataSource() + } + + let debugMenu = UIMenu(title: "Debug", options: .displayInline, children: [toggleDataSource]) + menuElements.append(debugMenu) + } + } else { + // Add "Try New Stats" option if feature is available but not enabled + let tryNewStatsAction = UIAction( + title: Strings.tryNewStats, + image: UIImage(systemName: "sparkles") + ) { [weak self] _ in + self?.enableNewStats() + } + menuElements.append(tryNewStatsAction) + } + + return UIMenu(children: menuElements) + } + + private func enableNewStats() { + WPAnalytics.track(.statsNewStatsEnabled) + + FeatureFlagOverrideStore().override(FeatureFlag.newStats, withValue: true) + + // Update the traffic view controller to show new stats + guard let trafficVC = createNewTrafficViewController() else { + return + } + + trafficTableViewController = trafficVC + pageViewController?.setViewControllers([trafficTableViewController], direction: .forward, animated: false) + configureNavBar() + } + + private func disableNewStats() { + WPAnalytics.track(.statsNewStatsDisabled) + + FeatureFlagOverrideStore().override(FeatureFlag.newStats, withValue: false) + + trafficTableViewController = createClassicTrafficViewController() + pageViewController?.setViewControllers([trafficTableViewController], direction: .forward, animated: false) + configureNavBar() + } + + private func toggleDataSource() { + isUsingMockData.toggle() + + // Update the traffic view controller with new data source + guard let trafficVC = createNewTrafficViewController() else { + return + } + + trafficTableViewController = trafficVC + pageViewController?.setViewControllers([trafficTableViewController], direction: .forward, animated: false) + + // Update menu to reflect new state + statsMenuButton.menu = createStatsMenu() + + // Show notice indicating the change + let message = isUsingMockData ? "Using mock data" : "Using real data" + Notice(title: message).post() + } + + @available(iOS 17, *) + private func showNewStatsTip() { + guard let button = parent?.navigationItem.rightBarButtonItem else { return } + + tipObserver?.cancel() + tipObserver = registerTipPopover( + AppTips.NewStatsTip(), + sourceItem: button, + arrowDirection: .up + ) { [weak self] action in + guard let self else { return } + if action.id == "try-new-stats" { + self.enableNewStats() + if self.presentedViewController is TipUIPopoverViewController { + self.dismiss(animated: true) + } + } + } + } + + private func showFeedbackView() { + present(SubmitFeedbackViewController(source: "new_stats", feedbackPrefix: "Stats"), animated: true) + } + public override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) removeWillEnterForegroundObserver() @@ -187,17 +382,16 @@ private extension SiteStatsDashboardViewController { private extension SiteStatsDashboardViewController { func setupFilterBar() { - WPStyleGuide.Stats.configureFilterTabBar(filterTabBar) - filterTabBar.isAutomaticTabSizingStyleEnabled = true + WPStyleGuide.configureFilterTabBar(filterTabBar) + filterTabBar.configureModernStyle() filterTabBar.items = displayedTabs filterTabBar.addTarget(self, action: #selector(selectedFilterDidChange(_:)), for: .valueChanged) filterTabBar.accessibilityIdentifier = "site-stats-dashboard-filter-bar" - filterTabBar.backgroundColor = .systemBackground } @objc func selectedFilterDidChange(_ filterBar: FilterTabBar) { currentSelectedTab = displayedTabs[filterBar.selectedIndex] - + UIImpactFeedbackGenerator(style: .soft).impactOccurred() configureNavBar() } } @@ -255,7 +449,9 @@ private extension SiteStatsDashboardViewController { direction: .forward, animated: false) } else { - trafficTableViewController.refreshData() + if let periodVC = trafficTableViewController as? SiteStatsPeriodTableViewController { + periodVC.refreshData() + } } case .subscribers: if oldSelectedTab != .subscribers || pageViewControllerIsEmpty { @@ -345,3 +541,25 @@ struct SiteStatsDashboardPreferences { private static let lastSelectedStatsDateKey = "LastSelectedStatsDate" } + +// MARK: - Strings + +private enum Strings { + static let sendFeedback = NSLocalizedString( + "stats.menu.sendFeedback", + value: "Send Feedback", + comment: "Menu item to send feedback about new stats experience" + ) + + static let switchToClassic = NSLocalizedString( + "stats.menu.switchToClassic", + value: "Switch to Classic Stats", + comment: "Menu item to switch back to classic stats experience" + ) + + static let tryNewStats = NSLocalizedString( + "stats.menu.tryNewStats", + value: "Try New Stats", + comment: "Menu item to enable new stats experience" + ) +} diff --git a/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift b/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift new file mode 100644 index 000000000000..aca054081fc4 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift @@ -0,0 +1,150 @@ +import UIKit +import SwiftUI +import JetpackStats +import WordPressKit +import WordPressShared +import Gravatar +import BuildSettingsKit + +/// A UIViewController wrapper for the new SwiftUI StatsMainView +class StatsHostingViewController: UIViewController { + static func makeNewTrafficViewController(blog: Blog? = nil, parentViewController: UIViewController, isDemo: Bool = false) -> UIViewController? { + let context: StatsContext + if isDemo { + context = StatsContext.demo + } else { + guard let blog, let blogContext = StatsContext(blog: blog) else { + return nil + } + context = blogContext + } + + let statsView = StatsMainView( + context: context, + router: StatsRouter(viewController: parentViewController), + showTabs: false + ) + let hostingController = SafeAreaHostingController(rootView: statsView) + + return hostingController + } + + static func makeStatsViewController(for blog: Blog) -> UIViewController { + let statsVC = StatsViewController() + statsVC.blog = blog + statsVC.hidesBottomBarWhenPushed = true + statsVC.navigationItem.largeTitleDisplayMode = .never + return statsVC + } +} + +extension StatsContext { + init?(blog: Blog) { + guard let siteID = blog.dotComID?.intValue, + let api = blog.account?.wordPressComRestApi else { + wpAssertionFailure("required context missing") + return nil + } + self.init( + timeZone: blog.timeZone ?? .current, + siteID: siteID, + api: api + ) + + // Configure avatar preprocessing using Gravatar + self.preprocessAvatar = { url, size in + // Use AvatarURL from Gravatar to update the URL to the requested pixel size + guard let avatarURL = AvatarURL(url: url) else { + return url + } + let options = AvatarQueryOptions(preferredSize: .points(size)) + return avatarURL.replacing(options: options)?.url ?? url + } + + // Configure analytics tracker + self.tracker = WPAnalyticsStatsTracker() + } +} + +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 { + ReaderCommentsViewController( + postID: NSNumber(value: postID), + siteID: siteID as NSNumber + ) + } +} + +/// A custom UIHostingController that properly handles safe area insets when embedded in containers like UIPageViewController +private class SafeAreaHostingController: UIHostingController { + private var safeAreaObservation: NSKeyValueObservation? + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + setupSafeAreaObservation() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + safeAreaObservation?.invalidate() + safeAreaObservation = nil + } + + private func setupSafeAreaObservation() { + // Find the root view controller (should be SiteStatsDashboardViewController or its parent) + var rootViewController: UIViewController? = self + while let parent = rootViewController?.parent { + rootViewController = parent + } + + guard let rootView = rootViewController?.view else { return } + + // Observe changes to the root view's safe area insets + safeAreaObservation = rootView.observe(\.safeAreaInsets, options: [.initial, .new]) { [weak self] view, _ in + self?.updateSafeAreaInsets(from: view) + } + } + + private func updateSafeAreaInsets(from rootView: UIView) { + // Apply the root view's bottom safe area inset + let bottomInset = rootView.safeAreaInsets.bottom + if additionalSafeAreaInsets.bottom != bottomInset { + additionalSafeAreaInsets = UIEdgeInsets(top: 0, left: 0, bottom: min(20, bottomInset), right: 0) + } + } +} + +// MARK: - WPAnalyticsStatsTracker + +/// A StatsTracker implementation that bridges JetpackStats analytics to WPAnalytics +private final class WPAnalyticsStatsTracker: StatsTracker { + func send(_ event: StatsEvent, properties: [String: String]) { + // Convert String properties to [AnyHashable: Any] + let wpProperties: [AnyHashable: Any] = properties.reduce(into: [:]) { result, pair in + result[pair.key] = pair.value + } + + WPAnalytics.track(event.wpEvent, properties: wpProperties) + } +} 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() + } +} 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 387511cf450c..d9920058fac5 100644 --- a/WordPress/Classes/ViewRelated/Stats/StatsViewController.swift +++ b/WordPress/Classes/ViewRelated/Stats/StatsViewController.swift @@ -14,5 +14,4 @@ extension StatsViewController { controller.view.translatesAutoresizingMaskIntoConstraints = false controller.view.pinEdges() } - } diff --git a/WordPress/Classes/ViewRelated/Stats/Traffic/StatsTrafficDatePickerView.swift b/WordPress/Classes/ViewRelated/Stats/Traffic/StatsTrafficDatePickerView.swift index d5f1d034284f..fc26c768fed4 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: + return NSLocalizedString("stats.traffic.hours", value: "Hours", comment: "The label for the option to show Stats Traffic chart for Days.") 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..73039797c2ed 100644 --- a/WordPress/Classes/ViewRelated/Stats/Traffic/StatsTrafficDatePickerViewModel.swift +++ b/WordPress/Classes/ViewRelated/Stats/Traffic/StatsTrafficDatePickerViewModel.swift @@ -73,6 +73,9 @@ private extension StatsPeriodUnit { var dateFormatter: DateFormatter { let format: String switch self { + case .hour: + wpAssertionFailure("unsupported") + format = "MMMM d, yyyy" case .day: format = "MMMM d, yyyy" case .week: @@ -89,6 +92,9 @@ private extension StatsPeriodUnit { var event: WPAnalyticsStat { switch self { + case .hour: + wpAssertionFailure("unsupported") + return .statsPeriodDaysAccessed case .day: return .statsPeriodDaysAccessed case .week: diff --git a/WordPress/Classes/ViewRelated/System/FilterTabBar.swift b/WordPress/Classes/ViewRelated/System/FilterTabBar.swift index f2191c3cb8c3..e921043ad45f 100644 --- a/WordPress/Classes/ViewRelated/System/FilterTabBar.swift +++ b/WordPress/Classes/ViewRelated/System/FilterTabBar.swift @@ -228,6 +228,28 @@ public class FilterTabBar: UIControl { var tabAttributedButtonInsets: UIEdgeInsets = AppearanceMetrics.buttonInsetsAttributedTitle var tabSeparatorPadding: CGFloat = AppearanceMetrics.buttonPadding + // MARK: - Modern Style Configuration + + func configureModernStyle() { + isAutomaticTabSizingStyleEnabled = true + + // Apply modern tab appearance with larger fonts and padding + tabsFont = UIFont.preferredFont(forTextStyle: .headline).withWeight(.regular) + tabsSelectedFont = UIFont.preferredFont(forTextStyle: .headline) + tabButtonInsets = UIEdgeInsets(top: 16, left: 20, bottom: 16, right: 20) + tabBarHeight = 54 + + tintColor = UIColor.label + selectedTitleColor = UIColor.label + deselectedTabColor = UIColor.secondaryLabel + backgroundColor = .systemBackground + + // Configure selection indicator for modern style + selectionIndicator.layer.cornerRadius = 2.0 + + refreshTabs() + } + // MARK: - Initialization public required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) 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]; diff --git a/WordPress/Classes/ViewRelated/Tips/AppTips.swift b/WordPress/Classes/ViewRelated/Tips/AppTips.swift index 799410e5f8fb..897523b875b3 100644 --- a/WordPress/Classes/ViewRelated/Tips/AppTips.swift +++ b/WordPress/Classes/ViewRelated/Tips/AppTips.swift @@ -54,6 +54,35 @@ enum AppTips { MaxDisplayCount(1) } } + + @available(iOS 17, *) + struct NewStatsTip: Tip { + let id = "new_stats_tip" + + var title: Text { + Text(NSLocalizedString("tips.newStats.title", value: "Try New Stats", comment: "Tip for new stats feature")) + } + + var message: Text? { + Text(NSLocalizedString("tips.newStats.message", value: "Experience new sleek and powerful stats. Switch back whenever you like.", comment: "Tip for new stats feature")) + } + + var image: Image? { + Image(systemName: "wand.and.sparkles.inverse") + } + + var actions: [Action] { + Action(id: "try-new-stats", title: NSLocalizedString( + "tips.newStats.action", + value: "Enable Now", + comment: "Action button title to enable new stats from tip" + )) + } + + var options: [any TipOption] { + MaxDisplayCount(1) + } + } } extension UIViewController {