Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Modules/Sources/JetpackStats/Cards/ChartCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions Modules/Sources/JetpackStats/Cards/ChartCardViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ final class ChartCardViewModel: ObservableObject, TrafficCardViewModel {
return output
}

var tabViewData: [MetricsOverviewTabView.MetricData] {
var tabViewData: [MetricsOverviewTabView<SiteMetric>.MetricData] {
metrics.map { metric in
if let chartData = chartData[metric] {
return .init(
Expand All @@ -215,7 +215,7 @@ final class ChartCardViewModel: ObservableObject, TrafficCardViewModel {
}
}

var placeholderTabViewData: [MetricsOverviewTabView.MetricData] {
var placeholderTabViewData: [MetricsOverviewTabView<SiteMetric>.MetricData] {
metrics.map { metric in
.init(metric: metric, value: 12345, previousValue: 11234)
}
Expand Down
4 changes: 2 additions & 2 deletions Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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: {
Expand Down
172 changes: 172 additions & 0 deletions Modules/Sources/JetpackStats/Cards/WordAdsChartCard.swift
Original file line number Diff line number Diff line change
@@ -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)
}
143 changes: 143 additions & 0 deletions Modules/Sources/JetpackStats/Cards/WordAdsChartCardViewModel.swift
Original file line number Diff line number Diff line change
@@ -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<Void, Never>?

// MARK: - Computed Properties

var tabViewData: [MetricsOverviewTabView<WordAdsMetric>.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
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import SwiftUI

struct ChartAverageAnnotation: View {
let value: Int
let formatter: StatsValueFormatter
let formatter: any ValueFormatterProtocol

@Environment(\.colorScheme) private var colorScheme

Expand Down
Loading