diff --git a/Modules/Sources/JetpackStats/Cards/ChartCard.swift b/Modules/Sources/JetpackStats/Cards/ChartCard.swift index 6aa1729ba84c..9685ee68c913 100644 --- a/Modules/Sources/JetpackStats/Cards/ChartCard.swift +++ b/Modules/Sources/JetpackStats/Cards/ChartCard.swift @@ -69,7 +69,7 @@ struct ChartCard: View { private func headerView(for metric: SiteMetric) -> some View { HStack(alignment: .center) { - StatsCardTitleView(title: metric.localizedTitle, showChevron: false) + StatsCardTitleView(title: metric.localizedTitle) Spacer(minLength: 0) } .accessibilityElement(children: .combine) @@ -101,7 +101,7 @@ struct ChartCard: View { ) } else if viewModel.isFirstLoad { ChartValuesSummaryView( - trend: .init(currentValue: 100, previousValue: 10, metric: .views), + trend: .init(currentValue: 100, previousValue: 10, metric: SiteMetric.views), style: .compact ) .redacted(reason: .placeholder) diff --git a/Modules/Sources/JetpackStats/Cards/ChartCardViewModel.swift b/Modules/Sources/JetpackStats/Cards/ChartCardViewModel.swift index 298f466494ba..8d51a2ba3879 100644 --- a/Modules/Sources/JetpackStats/Cards/ChartCardViewModel.swift +++ b/Modules/Sources/JetpackStats/Cards/ChartCardViewModel.swift @@ -201,7 +201,7 @@ final class ChartCardViewModel: ObservableObject, TrafficCardViewModel { return output } - var tabViewData: [MetricsOverviewTabView.MetricData] { + var tabViewData: [MetricsOverviewTabView.MetricData] { metrics.map { metric in if let chartData = chartData[metric] { return .init( @@ -215,7 +215,7 @@ final class ChartCardViewModel: ObservableObject, TrafficCardViewModel { } } - var placeholderTabViewData: [MetricsOverviewTabView.MetricData] { + var placeholderTabViewData: [MetricsOverviewTabView.MetricData] { metrics.map { metric in .init(metric: metric, value: 12345, previousValue: 11234) } diff --git a/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift b/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift index 4bb90f0ac756..a3faad6e04c7 100644 --- a/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift +++ b/Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift @@ -88,7 +88,7 @@ struct StandaloneChartCard: View { ) } else { ChartValuesSummaryView( - trend: .init(currentValue: 100, previousValue: 10, metric: .views), + trend: .init(currentValue: 100, previousValue: 10, metric: SiteMetric.views), style: .compact ) .redacted(reason: .placeholder) @@ -229,7 +229,7 @@ struct StandaloneChartCard: View { } @ViewBuilder - private func navigationButton(direction: Calendar.NavigationDirection) -> some View { + private func navigationButton(direction: NavigationDirection) -> some View { Button { dateRange = dateRange.navigate(direction) } label: { diff --git a/Modules/Sources/JetpackStats/Cards/WordAdsChartCard.swift b/Modules/Sources/JetpackStats/Cards/WordAdsChartCard.swift new file mode 100644 index 000000000000..c007dc37b6ca --- /dev/null +++ b/Modules/Sources/JetpackStats/Cards/WordAdsChartCard.swift @@ -0,0 +1,172 @@ +import SwiftUI + +/// A chart card for displaying WordAds metrics with granularity selection and period navigation. +struct WordAdsChartCard: View { + @ObservedObject var viewModel: WordAdsChartCardViewModel + + @ScaledMetric(relativeTo: .body) private var chartHeight: CGFloat = 180 + + var body: some View { + VStack(spacing: 0) { + header + .padding(.horizontal, Constants.step3) + .padding(.top, Constants.step3) + .padding(.bottom, Constants.step1) + + chartArea + .frame(height: chartHeight) + .padding(.horizontal, Constants.step2) + .padding(.vertical, Constants.step2) + .animation(.spring, value: viewModel.selectedMetric) + .animation(.easeInOut, value: viewModel.isFirstLoad) + + Divider() + + footer + } + .onAppear { + viewModel.onAppear() + } + .cardStyle() + } + + // MARK: - Header + + private var header: some View { + HStack(spacing: 0) { + StatsCardTitleView(title: viewModel.formattedCurrentDate) + + Spacer(minLength: 8) + + granularityMenu + } + } + + private var granularityMenu: some View { + Menu { + ForEach(DateRangeGranularity.allCases.filter { $0 != .hour }) { granularity in + Button { + viewModel.onGranularityChanged(granularity) + } label: { + Text(granularity.localizedTitle) + } + } + } label: { + HStack(spacing: 4) { + Text(viewModel.selectedGranularity.localizedTitle) + .font(.subheadline.weight(.medium)) + Image(systemName: "chevron.up.chevron.down") + .font(.caption2.weight(.semibold)) + } + .foregroundStyle(Color.primary) + } + .accessibilityLabel(Strings.Chart.granularity) + } + + // MARK: - Chart Area + + @ViewBuilder + private var chartArea: some View { + if viewModel.isFirstLoad { + loadingView + } else if let data = viewModel.currentChartData { + if data.isEmpty { + loadingErrorView(with: Strings.Chart.empty) + } else { + chartView(data: data) + .transition(.opacity.combined(with: .scale(scale: 0.97))) + } + } else { + loadingErrorView(with: viewModel.loadingError?.localizedDescription ?? Strings.Errors.generic) + } + } + + private var loadingView: some View { + SimpleBarChartView( + data: SimpleChartData.mock( + metric: viewModel.selectedMetric, + granularity: viewModel.selectedGranularity, + dataPointCount: viewModel.selectedGranularity.preferredQuantity + ), + selectedDate: nil, + onBarTapped: { _ in } + ) + .redacted(reason: .placeholder) + .opacity(0.2) + .pulsating() + } + + private func loadingErrorView(with message: String) -> some View { + SimpleBarChartView( + data: SimpleChartData.mock( + metric: viewModel.selectedMetric, + granularity: viewModel.selectedGranularity, + dataPointCount: viewModel.selectedGranularity.preferredQuantity + ), + selectedDate: nil, + onBarTapped: { _ in } + ) + .redacted(reason: .placeholder) + .grayscale(1) + .opacity(0.1) + .overlay { + SimpleErrorView(message: message) + } + } + + private func chartView(data: SimpleChartData) -> some View { + SimpleBarChartView( + data: data, + selectedDate: viewModel.selectedBarDate, + onBarTapped: { date in + viewModel.onBarTapped(date) + } + ) + } + + // MARK: - Footer + + private var footer: some View { + MetricsOverviewTabView( + data: viewModel.tabViewData, + selectedMetric: $viewModel.selectedMetric, + onMetricSelected: { metric in + viewModel.onMetricSelected(metric) + }, + showTrend: false + ) + .background( + CardGradientBackground(metric: viewModel.selectedMetric) + ) + .animation(.easeInOut, value: viewModel.selectedMetric) + } +} + +// MARK: - Supporting Views + +private struct CardGradientBackground: View { + let metric: WordAdsMetric + + @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 + ) + } +} + +// MARK: - Preview + +#Preview { + WordAdsChartCard( + viewModel: WordAdsChartCardViewModel(service: MockStatsService()) + ) + .padding() + .background(Constants.Colors.background) +} diff --git a/Modules/Sources/JetpackStats/Cards/WordAdsChartCardViewModel.swift b/Modules/Sources/JetpackStats/Cards/WordAdsChartCardViewModel.swift new file mode 100644 index 000000000000..28483fa9eba9 --- /dev/null +++ b/Modules/Sources/JetpackStats/Cards/WordAdsChartCardViewModel.swift @@ -0,0 +1,143 @@ +import SwiftUI + +/// ViewModel managing state and interactions for the WordAds chart card. +@MainActor +final class WordAdsChartCardViewModel: ObservableObject { + // MARK: - Published Properties + + @Published private(set) var chartData: [WordAdsMetric: SimpleChartData] = [:] + @Published private(set) var isFirstLoad = true + @Published private(set) var loadingError: Error? + + @Published var selectedMetric: WordAdsMetric = .impressions + @Published var selectedGranularity: DateRangeGranularity = .day + @Published var currentDate = Date() + @Published var selectedBarDate: Date? + + // MARK: - Dependencies + + private let service: any StatsServiceProtocol + private var loadTask: Task? + + // MARK: - Computed Properties + + var tabViewData: [MetricsOverviewTabView.MetricData] { + WordAdsMetric.allMetrics.map { metric in + let data = chartData[metric] + let value: Int? = { + guard let selectedBarDate else { + return data?.currentTotal + } + // Find the data point matching the selected date + return data?.currentData.first { dataPoint in + Calendar.current.isDate( + dataPoint.date, + equalTo: selectedBarDate, + toGranularity: selectedGranularity.component + ) + }?.value + }() + + return MetricsOverviewTabView.MetricData( + metric: metric, + value: value, + previousValue: nil // No comparison for legacy chart + ) + } + } + + var formattedCurrentDate: String { + let formatter = StatsDateFormatter() + let dateToFormat = selectedBarDate ?? currentDate + return formatter.formatDate(dateToFormat, granularity: selectedGranularity, context: .regular) + } + + var currentChartData: SimpleChartData? { + chartData[selectedMetric] + } + + // MARK: - Initialization + + init(service: any StatsServiceProtocol) { + self.service = service + } + + // MARK: - Public Methods + + func onAppear() { + guard chartData.isEmpty else { return } + loadData() + } + + func onGranularityChanged(_ newGranularity: DateRangeGranularity) { + selectedGranularity = newGranularity + currentDate = Date() // Reset to current date + selectedBarDate = nil + loadData() + } + + func onBarTapped(_ date: Date) { + selectedBarDate = date + } + + func onMetricSelected(_ metric: WordAdsMetric) { + selectedMetric = metric + // Chart data already loaded, no need to reload + // Select the latest period for the new metric + if let latestDate = chartData[metric]?.currentData.last?.date { + selectedBarDate = latestDate + } + } + + // MARK: - Private Methods + + func loadData() { + // Cancel any existing load task + loadTask?.cancel() + + loadTask = Task { [weak self] in + guard let self else { return } + + self.loadingError = nil + + do { + let response = try await service.getWordAdsStats( + date: currentDate, + granularity: selectedGranularity + ) + + guard !Task.isCancelled else { return } + + // Transform response into chart data for each metric + var newChartData: [WordAdsMetric: SimpleChartData] = [:] + for metric in WordAdsMetric.allMetrics { + if let dataPoints = response.metrics[metric] { + let total = DataPoint.getTotalValue( + for: dataPoints, + metric: metric + ) ?? 0 + + newChartData[metric] = SimpleChartData( + metric: metric, + granularity: selectedGranularity, + currentTotal: total, + currentData: dataPoints + ) + } + } + + self.chartData = newChartData + self.isFirstLoad = false + + // Automatically select the latest period if no selection exists + if self.selectedBarDate == nil, let latestDate = newChartData[self.selectedMetric]?.currentData.last?.date { + self.selectedBarDate = latestDate + } + } catch { + guard !Task.isCancelled else { return } + self.loadingError = error + self.isFirstLoad = false + } + } + } +} diff --git a/Modules/Sources/JetpackStats/Charts/Helpers/ChartAverageAnnotation.swift b/Modules/Sources/JetpackStats/Charts/Helpers/ChartAverageAnnotation.swift index 10503e4f70a4..a610e08e59df 100644 --- a/Modules/Sources/JetpackStats/Charts/Helpers/ChartAverageAnnotation.swift +++ b/Modules/Sources/JetpackStats/Charts/Helpers/ChartAverageAnnotation.swift @@ -2,7 +2,7 @@ import SwiftUI struct ChartAverageAnnotation: View { let value: Int - let formatter: StatsValueFormatter + let formatter: any ValueFormatterProtocol @Environment(\.colorScheme) private var colorScheme diff --git a/Modules/Sources/JetpackStats/Charts/Helpers/SimpleChartData.swift b/Modules/Sources/JetpackStats/Charts/Helpers/SimpleChartData.swift new file mode 100644 index 000000000000..f6dce90d8d13 --- /dev/null +++ b/Modules/Sources/JetpackStats/Charts/Helpers/SimpleChartData.swift @@ -0,0 +1,56 @@ +import SwiftUI + +/// Simplified chart data structure for metrics without comparison period support. +final class SimpleChartData: Sendable { + let metric: any MetricType + let granularity: DateRangeGranularity + let currentTotal: Int + let currentData: [DataPoint] + let maxValue: Int + var isEmpty: Bool { + currentData.isEmpty || currentData.allSatisfy { $0.value == 0 } + } + + init( + metric: any MetricType, + granularity: DateRangeGranularity, + currentTotal: Int, + currentData: [DataPoint] + ) { + self.metric = metric + self.granularity = granularity + self.currentTotal = currentTotal + self.currentData = currentData + self.maxValue = currentData.map(\.value).max() ?? 0 + } + + /// Creates mock chart data for preview and testing purposes. + static func mock( + metric: any MetricType, + granularity: DateRangeGranularity = .day, + dataPointCount: Int = 7 + ) -> SimpleChartData { + let calendar = Calendar.current + let now = Date() + + var mockData: [DataPoint] = [] + for i in 0.. Void + + @State private var hoveredDate: Date? + @State private var isInteracting = false + + @Environment(\.context) var context + @Environment(\.colorScheme) private var colorScheme + + private var valueFormatter: any ValueFormatterProtocol { + data.metric.makeValueFormatter() + } + + private var currentAverage: Double { + guard !data.currentData.isEmpty else { return 0 } + return Double(data.currentTotal) / Double(data.currentData.count) + } + + var body: some View { + Chart { + currentPeriodBars + averageLine + peakAnnotation + selectionIndicator + } + .chartXAxis { xAxis } + .chartYAxis { yAxis } + .chartYScale(domain: yAxisDomain) + .chartLegend(.hidden) + .animation(.spring, value: ObjectIdentifier(data)) + .chartOverlay { proxy in + makeOverlayView(proxy: proxy) + } + .dynamicTypeSize(...DynamicTypeSize.xxxLarge) + } + + // 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: barWidth + ) + .foregroundStyle( + LinearGradient( + colors: [ + data.metric.primaryColor, + lighten(data.metric.primaryColor) + ], + startPoint: .top, + endPoint: .bottom + ) + ) + .cornerRadius(6) + .opacity(getBarOpacity(for: point)) + } + } + + private var barWidth: MarkDimension { + data.currentData.count <= 3 ? .fixed(32) : .automatic + } + + private func lighten(_ color: Color) -> Color { + if #available(iOS 18, *) { + color.mix(with: Color(.systemBackground), by: colorScheme == .light ? 0.4 : 0.15) + } else { + color.opacity(0.5) + } + } + + private func getBarOpacity(for point: DataPoint) -> CGFloat { + let dateToMatch = isInteracting ? hoveredDate : selectedDate + guard let dateToMatch else { return 1.0 } + + return context.calendar.isDate( + point.date, + equalTo: dateToMatch, + toGranularity: data.granularity.component + ) ? 1.0 : 0.5 + } + + @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 peakAnnotation: some ChartContent { + if let maxPoint = data.currentData.max(by: { $0.value < $1.value }), 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) { + PeakValueAnnotation(value: maxPoint.value, metric: data.metric) + // Hide when interacting to avoid clutter + .opacity(isInteracting ? 0 : 1) + } + } + } + + @ChartContentBuilder + private var selectionIndicator: some ChartContent { + let dateToMatch = isInteracting ? hoveredDate : selectedDate + if let dateToMatch, + let selectedPoint = data.currentData.first(where: { point in + context.calendar.isDate( + point.date, + equalTo: dateToMatch, + toGranularity: data.granularity.component + ) + }) { + // Subtle vertical background fill for entire bar area + RectangleMark( + x: .value("Date", selectedPoint.date, unit: data.granularity.component), + yStart: .value("Bottom", 0), + yEnd: .value("Top", yAxisDomain.upperBound), + width: barWidth + ) + .foregroundStyle(data.metric.primaryColor.opacity(colorScheme == .light ? 0.08 : 0.12)) + .zIndex(-1) + } + } + + // MARK: - Axis Configuration + + private var xAxis: some AxisContent { + if data.currentData.count == 1 { + AxisMarks(values: .stride(by: data.granularity.component, count: 1)) { value in + if let date = value.as(Date.self) { + AxisValueLabel { + ChartAxisDateLabel(date: date, granularity: data.granularity) + } + } + } + } else { + AxisMarks(values: .automatic) { 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 + } + // Add some padding above the max value + let padding = max(Int(Double(data.maxValue) * 0.33), 1) + return 0...(data.maxValue + padding) + } + + // MARK: - Gesture Handling + + private func makeOverlayView(proxy: ChartProxy) -> some View { + GeometryReader { geometry in + ChartGestureOverlay( + onTap: { location in + handleTap(at: location, proxy: proxy, geometry: geometry) + }, + onInteractionUpdate: { location in + isInteracting = true + if let date = getDate(at: location, proxy: proxy, geometry: geometry) { + hoveredDate = date + onBarTapped(date) + } + }, + onInteractionEnd: { + isInteracting = false + hoveredDate = nil + } + ) + } + } + + private func handleTap(at location: CGPoint, proxy: ChartProxy, geometry: GeometryProxy) { + guard !isInteracting, + let date = getDate(at: location, proxy: proxy, geometry: geometry) else { + return + } + onBarTapped(date) + } + + private func getDate(at location: CGPoint, proxy: ChartProxy, geometry: GeometryProxy) -> Date? { + guard let frame = proxy.plotFrame else { return nil } + + let origin = geometry[frame].origin + let adjustedX = location.x - origin.x + + return proxy.value(atX: adjustedX) + } +} + +// MARK: - Supporting Views + +private struct PeakValueAnnotation: View { + let value: Int + let metric: any MetricType + let valueFormatter: any ValueFormatterProtocol + + @Environment(\.colorScheme) private var colorScheme + + init(value: Int, metric: any MetricType) { + self.value = value + self.metric = metric + self.valueFormatter = metric.makeValueFormatter() + } + + 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)) + } + } + } +} + +// MARK: - Preview + +#Preview("Days") { + SimpleBarChartView( + data: SimpleChartData.mock(metric: WordAdsMetric.impressions, granularity: .day, dataPointCount: 7), + selectedDate: nil, + onBarTapped: { _ in } + ) + .frame(height: 180) + .padding() + .background(Constants.Colors.background) +} + +#Preview("With Selection") { + SimpleBarChartView( + data: SimpleChartData.mock(metric: WordAdsMetric.revenue, granularity: .day, dataPointCount: 7), + selectedDate: Date(), + onBarTapped: { _ in } + ) + .frame(height: 180) + .padding() + .background(Constants.Colors.background) +} + +#Preview("Months") { + SimpleBarChartView( + data: SimpleChartData.mock(metric: WordAdsMetric.cpm, granularity: .month, dataPointCount: 12), + selectedDate: nil, + onBarTapped: { _ in } + ) + .frame(height: 180) + .padding() + .background(Constants.Colors.background) +} diff --git a/Modules/Sources/JetpackStats/Extensions/Calendar+Navigation.swift b/Modules/Sources/JetpackStats/Extensions/Calendar+Navigation.swift index 815c35688567..27456a01de2c 100644 --- a/Modules/Sources/JetpackStats/Extensions/Calendar+Navigation.swift +++ b/Modules/Sources/JetpackStats/Extensions/Calendar+Navigation.swift @@ -1,18 +1,25 @@ import Foundation -extension Calendar { - enum NavigationDirection { - case backward - case forward - - var systemImage: String { - switch self { - case .backward: "chevron.backward" - case .forward: "chevron.forward" - } +enum NavigationDirection { + case backward + case forward + + var systemImage: String { + switch self { + case .backward: "chevron.backward" + case .forward: "chevron.forward" } } + var accessibilityLabel: String { + switch self { + case .forward: Strings.Accessibility.nextPeriod + case .backward: Strings.Accessibility.previousPeriod + } + } +} + +extension Calendar { /// 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. diff --git a/Modules/Sources/JetpackStats/Screens/AdsTabView.swift b/Modules/Sources/JetpackStats/Screens/AdsTabView.swift index 2a7464a96063..7c99f82bc448 100644 --- a/Modules/Sources/JetpackStats/Screens/AdsTabView.swift +++ b/Modules/Sources/JetpackStats/Screens/AdsTabView.swift @@ -1,28 +1,36 @@ import SwiftUI public struct AdsTabView: View { + @StateObject private var viewModel: WordAdsChartCardViewModel - public init() {} + public init(context: StatsContext, router: StatsRouter) { + _viewModel = StateObject( + wrappedValue: WordAdsChartCardViewModel(service: context.service) + ) + } public var body: some View { ScrollView { - VStack(spacing: 16) { - Text("Ads") - .font(.largeTitle) - .fontWeight(.bold) - .padding(.top, 20) - - Text("Coming Soon") - .font(.headline) - .foregroundColor(.secondary) - - Spacer(minLength: 100) + VStack(spacing: Constants.step3) { + WordAdsChartCard(viewModel: viewModel) } - .padding() + .padding(.vertical, Constants.step2) + .padding(.horizontal, Constants.step1) + .padding(.top, Constants.step0_5) } + .background(Constants.Colors.background) } } #Preview { - AdsTabView() + NavigationStack { + AdsTabView( + context: .demo, + router: StatsRouter( + viewController: UINavigationController(), + factory: MockStatsRouterScreenFactory() + ) + ) + .environment(\.context, .demo) + } } diff --git a/Modules/Sources/JetpackStats/Screens/AuthorStatsView.swift b/Modules/Sources/JetpackStats/Screens/AuthorStatsView.swift index 83e789ca5dc2..0cbb37c92fd2 100644 --- a/Modules/Sources/JetpackStats/Screens/AuthorStatsView.swift +++ b/Modules/Sources/JetpackStats/Screens/AuthorStatsView.swift @@ -138,7 +138,7 @@ struct AuthorStatsView: View { let trend = TrendViewModel( currentValue: current, previousValue: previous, - metric: .views + metric: SiteMetric.views ) HStack(spacing: 4) { diff --git a/Modules/Sources/JetpackStats/Screens/StatsMainView.swift b/Modules/Sources/JetpackStats/Screens/StatsMainView.swift index 4a7ba65aa5d9..0aaa20fd5ae2 100644 --- a/Modules/Sources/JetpackStats/Screens/StatsMainView.swift +++ b/Modules/Sources/JetpackStats/Screens/StatsMainView.swift @@ -67,7 +67,7 @@ public struct StatsMainView: View { context.tracker?.send(.subscribersTabShown) } case .ads: - AdsTabView() + AdsTabView(context: context, router: router) } } diff --git a/Modules/Sources/JetpackStats/Services/Data/MetricType.swift b/Modules/Sources/JetpackStats/Services/Data/MetricType.swift index 1ba6a8abeaa3..7eb4f3203f04 100644 --- a/Modules/Sources/JetpackStats/Services/Data/MetricType.swift +++ b/Modules/Sources/JetpackStats/Services/Data/MetricType.swift @@ -1,7 +1,7 @@ import SwiftUI /// Protocol defining the requirements for a metric type that can be displayed in stats views. -protocol MetricType: Identifiable, Hashable, Equatable { +protocol MetricType: Identifiable, Hashable, Equatable, Sendable { var localizedTitle: String { get } var systemImage: String { get } var primaryColor: Color { get } diff --git a/Modules/Sources/JetpackStats/Services/Data/WordAdsMetric.swift b/Modules/Sources/JetpackStats/Services/Data/WordAdsMetric.swift index 11587e3ee98d..a6e5b1a3e06a 100644 --- a/Modules/Sources/JetpackStats/Services/Data/WordAdsMetric.swift +++ b/Modules/Sources/JetpackStats/Services/Data/WordAdsMetric.swift @@ -54,7 +54,7 @@ struct WordAdsMetric: Identifiable, Sendable, Hashable, MetricType { id: "cpm", localizedTitle: Strings.WordAdsMetrics.averageCPM, systemImage: "chart.bar", - primaryColor: Constants.Colors.green, + primaryColor: Constants.Colors.celadon, aggregationStrategy: .average ) diff --git a/Modules/Sources/JetpackStats/Utilities/DateRangeGranularity.swift b/Modules/Sources/JetpackStats/Utilities/DateRangeGranularity.swift index 272511efb5d3..246836e67285 100644 --- a/Modules/Sources/JetpackStats/Utilities/DateRangeGranularity.swift +++ b/Modules/Sources/JetpackStats/Utilities/DateRangeGranularity.swift @@ -78,11 +78,11 @@ extension DateRangeGranularity { /// Used by legacy APIs that accept a date and quantity instead of date periods. var preferredQuantity: Int { switch self { - case .hour: 48 - case .day: 30 + case .hour: 24 + case .day: 14 case .week: 12 case .month: 12 - case .year: 10 + case .year: 6 } } } diff --git a/Modules/Sources/JetpackStats/Utilities/StatsDateRange.swift b/Modules/Sources/JetpackStats/Utilities/StatsDateRange.swift index 103f1da62ecc..287f465e8cfb 100644 --- a/Modules/Sources/JetpackStats/Utilities/StatsDateRange.swift +++ b/Modules/Sources/JetpackStats/Utilities/StatsDateRange.swift @@ -66,7 +66,7 @@ struct StatsDateRange: Equatable, Sendable { // MARK: - Navigation /// Navigates to the specified direction (previous or next period). - func navigate(_ direction: Calendar.NavigationDirection) -> StatsDateRange { + func navigate(_ direction: 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 @@ -74,7 +74,7 @@ struct StatsDateRange: Equatable, Sendable { } /// Returns true if can navigate in the specified direction. - func canNavigate(in direction: Calendar.NavigationDirection, now: Date = .now) -> Bool { + func canNavigate(in direction: NavigationDirection, now: Date = .now) -> Bool { calendar.canNavigate(dateInterval, direction: direction, now: now) } @@ -84,7 +84,7 @@ struct StatsDateRange: Equatable, Sendable { /// - maxCount: Maximum number of periods to generate (default: 10) /// - now: The reference date for determining navigation bounds (default: .now) /// - Returns: Array of AdjacentPeriod structs - func availableAdjacentPeriods(in direction: Calendar.NavigationDirection, maxCount: Int = 10, now: Date = .now) -> [AdjacentPeriod] { + func availableAdjacentPeriods(in direction: NavigationDirection, maxCount: Int = 10, now: Date = .now) -> [AdjacentPeriod] { var periods: [AdjacentPeriod] = [] var currentRange = self let formatter = StatsDateRangeFormatter(timeZone: calendar.timeZone, now: { now }) diff --git a/Modules/Sources/JetpackStats/Utilities/TrendViewModel.swift b/Modules/Sources/JetpackStats/Utilities/TrendViewModel.swift index a8a781990d5f..6149fee028d5 100644 --- a/Modules/Sources/JetpackStats/Utilities/TrendViewModel.swift +++ b/Modules/Sources/JetpackStats/Utilities/TrendViewModel.swift @@ -3,12 +3,19 @@ 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 { +struct TrendViewModel: Equatable { let currentValue: Int let previousValue: Int - let metric: SiteMetric + let metric: any MetricType var context: StatsValueFormatter.Context = .compact + static func == (lhs: TrendViewModel, rhs: TrendViewModel) -> Bool { + lhs.currentValue == rhs.currentValue && + lhs.previousValue == rhs.previousValue && + lhs.context == rhs.context && + String(describing: type(of: lhs.metric)) == String(describing: type(of: rhs.metric)) + } + /// The sign prefix for the change value. var sign: String { currentValue >= previousValue ? "+" : "-" @@ -75,7 +82,7 @@ struct TrendViewModel: Hashable { } private func formattedValue(_ value: Int) -> String { - StatsValueFormatter(metric: metric) + metric.makeValueFormatter() .format(value: value, context: context) } diff --git a/Modules/Sources/JetpackStats/Views/BadgeTrendIndicator.swift b/Modules/Sources/JetpackStats/Views/BadgeTrendIndicator.swift index 02d07e271708..0a219ae27d48 100644 --- a/Modules/Sources/JetpackStats/Views/BadgeTrendIndicator.swift +++ b/Modules/Sources/JetpackStats/Views/BadgeTrendIndicator.swift @@ -28,19 +28,19 @@ struct BadgeTrendIndicator: View { VStack(spacing: 20) { Text("Examples").font(.headline) // 15% increase in views - positive sentiment - BadgeTrendIndicator(trend: TrendViewModel(currentValue: 115, previousValue: 100, metric: .views)) + BadgeTrendIndicator(trend: TrendViewModel(currentValue: 115, previousValue: 100, metric: SiteMetric.views)) // 15% decrease in views - negative sentiment - BadgeTrendIndicator(trend: TrendViewModel(currentValue: 85, previousValue: 100, metric: .views)) + BadgeTrendIndicator(trend: TrendViewModel(currentValue: 85, previousValue: 100, metric: SiteMetric.views)) // 0.1% increase in views - negative sentiment - BadgeTrendIndicator(trend: TrendViewModel(currentValue: 1001, previousValue: 1000, metric: .views)) + BadgeTrendIndicator(trend: TrendViewModel(currentValue: 1001, previousValue: 1000, metric: SiteMetric.views)) Text("Edge Cases").font(.headline).padding(.top) // No change - BadgeTrendIndicator(trend: TrendViewModel(currentValue: 100, previousValue: 100, metric: .views)) + BadgeTrendIndicator(trend: TrendViewModel(currentValue: 100, previousValue: 100, metric: SiteMetric.views)) // Division by zero (from 0 to 100) - BadgeTrendIndicator(trend: TrendViewModel(currentValue: 100, previousValue: 0, metric: .views)) + BadgeTrendIndicator(trend: TrendViewModel(currentValue: 100, previousValue: 0, metric: SiteMetric.views)) // Large change - BadgeTrendIndicator(trend: TrendViewModel(currentValue: 400, previousValue: 100, metric: .views)) + BadgeTrendIndicator(trend: TrendViewModel(currentValue: 400, previousValue: 100, metric: SiteMetric.views)) } .padding() } diff --git a/Modules/Sources/JetpackStats/Views/ChartValuesSummaryView.swift b/Modules/Sources/JetpackStats/Views/ChartValuesSummaryView.swift index 81c253af18bd..6c33800ca65c 100644 --- a/Modules/Sources/JetpackStats/Views/ChartValuesSummaryView.swift +++ b/Modules/Sources/JetpackStats/Views/ChartValuesSummaryView.swift @@ -50,11 +50,11 @@ struct ChartValuesSummaryView: View { #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) + ChartValuesSummaryView(trend: .init(currentValue: 1000, previousValue: 500, metric: SiteMetric.views), style: style) + ChartValuesSummaryView(trend: .init(currentValue: 500, previousValue: 1000, metric: SiteMetric.views), style: style) + ChartValuesSummaryView(trend: .init(currentValue: 100, previousValue: 100, metric: SiteMetric.views), style: style) + ChartValuesSummaryView(trend: .init(currentValue: 56, previousValue: 60, metric: SiteMetric.bounceRate), style: style) + ChartValuesSummaryView(trend: .init(currentValue: 42, previousValue: 0, metric: SiteMetric.views), style: style) Divider() } } diff --git a/Modules/Sources/JetpackStats/Views/CountryMap/CountryTooltip.swift b/Modules/Sources/JetpackStats/Views/CountryMap/CountryTooltip.swift index c5b5e52285b7..1e2a58cfbeca 100644 --- a/Modules/Sources/JetpackStats/Views/CountryMap/CountryTooltip.swift +++ b/Modules/Sources/JetpackStats/Views/CountryMap/CountryTooltip.swift @@ -39,7 +39,7 @@ struct CountryTooltip: View { return TrendViewModel( currentValue: currentViews, previousValue: previousViews, - metric: .views + metric: SiteMetric.views ) } diff --git a/Modules/Sources/JetpackStats/Views/LegacyFloatingDateControl.swift b/Modules/Sources/JetpackStats/Views/LegacyFloatingDateControl.swift index 0814604723d4..2a48a4a0d655 100644 --- a/Modules/Sources/JetpackStats/Views/LegacyFloatingDateControl.swift +++ b/Modules/Sources/JetpackStats/Views/LegacyFloatingDateControl.swift @@ -91,7 +91,7 @@ struct LegacyFloatingDateControl: View { .floatingStyle() } - private func makeNavigationButton(direction: Calendar.NavigationDirection) -> some View { + private func makeNavigationButton(direction: NavigationDirection) -> some View { let isDisabled = !dateRange.canNavigate(in: direction) return Menu { ForEach(dateRange.availableAdjacentPeriods(in: direction)) { period in diff --git a/Modules/Sources/JetpackStats/Views/MetricsOverviewTabView.swift b/Modules/Sources/JetpackStats/Views/MetricsOverviewTabView.swift index d76219ca7c14..e0cabb027dbd 100644 --- a/Modules/Sources/JetpackStats/Views/MetricsOverviewTabView.swift +++ b/Modules/Sources/JetpackStats/Views/MetricsOverviewTabView.swift @@ -4,17 +4,18 @@ 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 { +struct MetricsOverviewTabView: View { /// Data for a single metric tab struct MetricData { - let metric: SiteMetric + let metric: Metric let value: Int? let previousValue: Int? } let data: [MetricData] - @Binding var selectedMetric: SiteMetric - var onMetricSelected: ((SiteMetric) -> Void)? + @Binding var selectedMetric: Metric + var onMetricSelected: ((Metric) -> Void)? + var showTrend: Bool = true @ScaledMetric(relativeTo: .title) private var minTabWidth: CGFloat = 100 @@ -35,12 +36,12 @@ struct MetricsOverviewTabView: View { } private func makeItemView(for item: MetricData, onTap: @escaping () -> Void) -> some View { - MetricItemView(data: item, isSelected: selectedMetric == item.metric, onTap: onTap) + MetricItemView(data: item, isSelected: selectedMetric == item.metric, showTrend: showTrend, onTap: onTap) .frame(minWidth: minTabWidth) .id(item.metric) } - private func selectDataType(_ type: SiteMetric, proxy: ScrollViewProxy) { + private func selectDataType(_ type: Metric, proxy: ScrollViewProxy) { withAnimation(.spring) { selectedMetric = type proxy.scrollTo(type, anchor: .center) @@ -50,13 +51,14 @@ struct MetricsOverviewTabView: View { } } -private struct MetricItemView: View { - let data: MetricsOverviewTabView.MetricData +private struct MetricItemView: View { + let data: MetricsOverviewTabView.MetricData let isSelected: Bool + let showTrend: Bool let onTap: () -> Void - private var valueFormatter: StatsValueFormatter { - StatsValueFormatter(metric: data.metric) + private var valueFormatter: any ValueFormatterProtocol { + data.metric.makeValueFormatter() } private var formattedValue: String { @@ -97,9 +99,11 @@ private struct MetricItemView: View { private var headerView: some View { HStack(spacing: 2) { - Image(systemName: data.metric.systemImage) - .font(.caption2.weight(.medium)) - .scaleEffect(x: 0.9, y: 0.9) + if showTrend { + 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)) } @@ -117,15 +121,17 @@ private struct MetricItemView: View { .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) + if showTrend { + 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) @@ -147,7 +153,7 @@ private struct MetricItemView: View { #if DEBUG #Preview { - let mockData: [MetricsOverviewTabView.MetricData] = [ + 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), diff --git a/Modules/Sources/JetpackStats/Views/StatsDateRangeButtons.swift b/Modules/Sources/JetpackStats/Views/StatsDateRangeButtons.swift index ded47f4805b8..41bf7186d5b9 100644 --- a/Modules/Sources/JetpackStats/Views/StatsDateRangeButtons.swift +++ b/Modules/Sources/JetpackStats/Views/StatsDateRangeButtons.swift @@ -51,7 +51,7 @@ struct StatsDatePickerToolbarItem: View { struct StatsNavigationButton: View { @Binding var dateRange: StatsDateRange - let direction: Calendar.NavigationDirection + let direction: NavigationDirection @Environment(\.context) var context diff --git a/Modules/Sources/WordPressKit/StatsWordAdsResponse.swift b/Modules/Sources/WordPressKit/StatsWordAdsResponse.swift index 7fffeaf9aee8..7c8b4439f4f0 100644 --- a/Modules/Sources/WordPressKit/StatsWordAdsResponse.swift +++ b/Modules/Sources/WordPressKit/StatsWordAdsResponse.swift @@ -107,8 +107,7 @@ private func makeDateFormatter(for unit: StatsPeriodUnit) -> DateFormatter { formatter.dateFormat = { switch unit { case .hour: "yyyy-MM-dd HH:mm:ss" - case .week: "yyyy'W'MM'W'dd" - case .day, .month, .year: "yyyy-MM-dd" + case .day, .week, .month, .year: "yyyy-MM-dd" } }() return formatter diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 00cfe386c45d..064c2c06414c 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -1,6 +1,6 @@ 26.7 ----- - +* [**] Stats: Add new "Adds" tab to show WordAdds statistics [#25165] 26.6 ----- diff --git a/WordPress/Classes/ViewRelated/Stats/SiteStatsDashboardViewController.swift b/WordPress/Classes/ViewRelated/Stats/SiteStatsDashboardViewController.swift index 530642da72aa..241721d98c13 100644 --- a/WordPress/Classes/ViewRelated/Stats/SiteStatsDashboardViewController.swift +++ b/WordPress/Classes/ViewRelated/Stats/SiteStatsDashboardViewController.swift @@ -147,11 +147,11 @@ public class SiteStatsDashboardViewController: UIViewController { return StatsSubscribersViewController(viewModel: viewModel) }() - private lazy var adsViewController: UIViewController = { - let adsView = AdsTabView() - let hostingController = UIHostingController(rootView: adsView) - hostingController.view.backgroundColor = .systemBackground - return hostingController + private lazy var adsViewController: UIViewController? = { + guard let blog = Self.currentBlog() else { + return nil + } + return StatsHostingViewController.makeAdsViewController(blog: blog, parentViewController: self) }() // MARK: - View @@ -461,7 +461,9 @@ private extension SiteStatsDashboardViewController { } case .ads: if oldSelectedTab != .ads || containerIsEmpty { - showChildViewController(adsViewController) + if let adsViewController { + showChildViewController(adsViewController) + } } } } diff --git a/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift b/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift index 471ba6c9e6b8..66591cbbde8f 100644 --- a/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift +++ b/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift @@ -41,6 +41,20 @@ class StatsHostingViewController: UIViewController { statsVC.navigationItem.largeTitleDisplayMode = .never return statsVC } + + static func makeAdsViewController(blog: Blog, parentViewController: UIViewController) -> UIViewController? { + guard let context = StatsContext(blog: blog) else { + return nil + } + + let adsView = AdsTabView( + context: context, + router: StatsRouter(viewController: parentViewController) + ) + let hostingController = UIHostingController(rootView: adsView) + hostingController.view.backgroundColor = .systemBackground + return hostingController + } } extension StatsContext {