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
19 changes: 19 additions & 0 deletions Modules/Sources/JetpackStats/Analytics/StatsEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,12 @@ public enum StatsEvent {
/// - "to_type": New chart type
case chartTypeChanged

/// Chart granularity changed
/// - Parameters:
/// - "from": Previous granularity (e.g., "day", "week", "automatic")
/// - "to": New granularity
case chartGranularityChanged

/// Chart metric selected
/// - Parameters:
/// - "metric": The metric selected (e.g., "visitors", "views", "likes")
Expand Down Expand Up @@ -200,3 +206,16 @@ extension SiteMetric {
}
}
}

extension DateRangeGranularity {
/// Analytics tracking name for the granularity
var analyticsName: String {
switch self {
case .hour: "hour"
case .day: "day"
case .week: "week"
case .month: "month"
case .year: "year"
}
}
}
48 changes: 44 additions & 4 deletions Modules/Sources/JetpackStats/Cards/ChartCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -180,14 +180,20 @@ struct ChartCard: View {

@ViewBuilder
private var moreMenuContent: some View {
chartTypeSection
granularitySection
dataSection
EditCardMenuContent(cardViewModel: viewModel)
}

private var chartTypeSection: some View {
Section {
ControlGroup {
ForEach(ChartType.allCases, id: \.self) { type in
ForEach(ChartType.allCases) { 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
Expand All @@ -198,6 +204,39 @@ struct ChartCard: View {
}
}
}
}

private var granularitySection: some View {
Section {
Menu {
granularityButton(for: nil)
let options: [DateRangeGranularity] = [.day, .week, .month, .year]
ForEach(options) { granularity in
granularityButton(for: granularity)
}
} label: {
Label(viewModel.effectiveGranularity.localizedTitle, systemImage: "calendar")
}
}
}

private func granularityButton(for granularity: DateRangeGranularity?) -> some View {
Button {
let previousGranularity = viewModel.selectedGranularity
viewModel.selectedGranularity = granularity
viewModel.tracker?.send(.chartGranularityChanged, properties: [
"from": previousGranularity?.analyticsName ?? "automatic",
"to": granularity?.analyticsName ?? "automatic"
])
} label: {
Label(
granularity?.localizedTitle ?? Strings.Granularity.automatic,
systemImage: viewModel.selectedGranularity == granularity ? "checkmark" : ""
)
}
}

private var dataSection: some View {
Section {
Button {
isShowingRawData = true
Expand All @@ -208,7 +247,6 @@ struct ChartCard: View {
Label(Strings.Buttons.learnMore, systemImage: "info.circle")
}
}
EditCardMenuContent(cardViewModel: viewModel)
}

// MARK: - Chart View
Expand Down Expand Up @@ -276,10 +314,12 @@ private struct CardGradientBackground: View {
}
}

public enum ChartType: String, CaseIterable, Codable {
public enum ChartType: String, CaseIterable, Identifiable, Codable {
case line
case columns

public var id: String { rawValue }

var localizedTitle: String {
switch self {
case .line: Strings.Chart.lineChart
Expand Down
25 changes: 19 additions & 6 deletions Modules/Sources/JetpackStats/Cards/ChartCardViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ final class ChartCardViewModel: ObservableObject, TrafficCardViewModel {

@Published var isEditing = false
@Published var selectedMetric: SiteMetric

@Published var selectedChartType: ChartType {
didSet {
// Update configuration when chart type changes
Expand All @@ -22,14 +23,29 @@ final class ChartCardViewModel: ObservableObject, TrafficCardViewModel {
}
}

@Published var selectedGranularity: DateRangeGranularity? {
didSet {
// Reload data with new granularity
loadData(for: dateRange)
}
}

weak var configurationDelegate: CardConfigurationDelegate?

var dateRange: StatsDateRange {
didSet {
// Reset granularity to automatic when date period changes (but not for adjacent navigation)
if !dateRange.isAdjacent(to: oldValue) {
selectedGranularity = nil
}
loadData(for: dateRange)
}
}

var effectiveGranularity: DateRangeGranularity {
selectedGranularity ?? dateRange.dateInterval.preferredGranularity
}

private let service: any StatsServiceProtocol
let tracker: (any StatsTracker)?

Expand Down Expand Up @@ -147,7 +163,7 @@ final class ChartCardViewModel: ObservableObject, TrafficCardViewModel {
private func getSiteStats(dateRange: StatsDateRange) async throws -> [SiteMetric: ChartData] {
var output: [SiteMetric: ChartData] = [:]

let granularity = dateRange.dateInterval.preferredGranularity
let granularity = selectedGranularity ?? dateRange.dateInterval.preferredGranularity

// Fetch both current and previous period data concurrently
async let currentResponseTask = service.getSiteStats(
Expand All @@ -167,11 +183,8 @@ final class ChartCardViewModel: ObservableObject, TrafficCardViewModel {
// 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
currentData: dataPoints,
previousData: previousDataPoints
)

output[metric] = ChartData(
Expand Down
7 changes: 2 additions & 5 deletions Modules/Sources/JetpackStats/Cards/StandaloneChartCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -282,11 +282,8 @@ private func generateChartData(

// 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
currentData: currentPeriod.dataPoints,
previousData: previousPeriod.dataPoints
)

return ChartData(
Expand Down
29 changes: 11 additions & 18 deletions Modules/Sources/JetpackStats/Services/Data/DataPoint.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,27 +12,20 @@ struct DataPoint: Identifiable, Sendable {

extension DataPoint {
/// Maps previous period data points to align with current period dates.
/// Takes the dates from current data and replaces values with corresponding previous data values.
/// Arrays are aligned from the end - if lengths differ, the beginning of the longer array is skipped.
/// - 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
/// - currentData: The data points from the current period (provides dates)
/// - previousData: The data points from the previous period (provides values)
/// - Returns: An array of data points with current dates and previous values
static func mapDataPoints(
_ dataPoits: [DataPoint],
from: DateInterval,
to: DateInterval,
component: Calendar.Component,
calendar: Calendar
currentData: [DataPoint],
previousData: [DataPoint]
) -> [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
)
}
// reversing to align by the last item in case there is a mismatch in the number of items
zip(currentData.reversed(), previousData.reversed()).map { current, previous in
DataPoint(date: current.date, value: previous.value)
}.reversed()
}

static func getTotalValue(for dataPoints: [DataPoint], metric: SiteMetric) -> Int? {
Expand Down
10 changes: 10 additions & 0 deletions Modules/Sources/JetpackStats/Strings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@ enum Strings {
static let year = AppLocalizedString("jetpackStats.calendar.year", value: "Year", comment: "Year time period")
}

enum Granularity {
static let automatic = AppLocalizedString("jetpackStats.granularity.automatic", value: "Automatic", comment: "Automatic granularity option")
static let hour = AppLocalizedString("jetpackStats.granularity.hours", value: "Hours", comment: "Hours granularity option")
static let day = AppLocalizedString("jetpackStats.granularity.days", value: "Days", comment: "Days granularity option")
static let week = AppLocalizedString("jetpackStats.granularity.weeks", value: "Weeks", comment: "Weeks granularity option")
static let month = AppLocalizedString("jetpackStats.granularity.months", value: "Months", comment: "Months granularity option")
static let year = AppLocalizedString("jetpackStats.granularity.years", value: "Years", comment: "Years granularity option")
}

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")
Expand Down Expand Up @@ -111,6 +120,7 @@ enum Strings {
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")
static let granularity = AppLocalizedString("jetpackStats.chart.granularity", value: "Granularity", comment: "Granularity picker label")
}

enum TopListTitles {
Expand Down
16 changes: 14 additions & 2 deletions Modules/Sources/JetpackStats/Utilities/DateRangeGranularity.swift
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import Foundation

enum DateRangeGranularity: Comparable {
enum DateRangeGranularity: Comparable, CaseIterable, Identifiable {
case hour
case day
case week
case month
case year

var id: Self { self }
}

extension DateInterval {
Expand All @@ -21,7 +23,7 @@ extension DateInterval {
if totalDays <= 1 {
return .hour
}
// For ranges 2-90 days: show daily data (2-90 points)
// For ranges 2-31 days: show daily data (2-31 points)
else if totalDays <= 31 {
return .day
}
Expand All @@ -40,6 +42,16 @@ extension DateInterval {
}

extension DateRangeGranularity {
var localizedTitle: String {
switch self {
case .hour: Strings.Granularity.hour
case .day: Strings.Granularity.day
case .week: Strings.Granularity.week
case .month: Strings.Granularity.month
case .year: Strings.Granularity.year
}
}

/// Components needed to aggregate data at this granularity
var calendarComponents: Set<Calendar.Component> {
switch self {
Expand Down
Loading