Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import Foundation
import Yosemite
import WooFoundation

/// Helpers for calculating and formatting stats data for display.
///
struct StatsDataTextFormatter {

// MARK: Revenue Stats

/// Creates the text to display for the total revenue.
///
static func createTotalRevenueText(orderStats: OrderStatsV4?,
selectedIntervalIndex: Int?,
currencyFormatter: CurrencyFormatter?,
currencyCode: String) -> String {
if let revenue = totalRevenue(at: selectedIntervalIndex, orderStats: orderStats) {
// If revenue is an integer, no decimal points are shown.
let numberOfDecimals: Int? = revenue.isInteger ? 0: nil
let currencyFormatter = currencyFormatter ?? CurrencyFormatter(currencySettings: ServiceLocator.currencySettings)
return currencyFormatter.formatAmount(revenue, with: currencyCode, numberOfDecimals: numberOfDecimals) ?? String()
} else {
return Constants.placeholderText
}
}

// MARK: Orders Stats

/// Creates the text to display for the order count.
///
static func createOrderCountText(orderStats: OrderStatsV4?, selectedIntervalIndex: Int?) -> String {
if let count = orderCount(at: selectedIntervalIndex, orderStats: orderStats) {
return Double(count).humanReadableString()
} else {
return Constants.placeholderText
}
}

/// Creates the text to display for the average order value.
///
static func createAverageOrderValueText(orderStats: OrderStatsV4?, currencyFormatter: CurrencyFormatter, currencyCode: String) -> String {
if let value = averageOrderValue(orderStats: orderStats) {
// If order value is an integer, no decimal points are shown.
let numberOfDecimals: Int? = value.isInteger ? 0 : nil
return currencyFormatter.formatAmount(value, with: currencyCode, numberOfDecimals: numberOfDecimals) ?? String()
} else {
return Constants.placeholderText
}
}

// MARK: Views and Visitors Stats

/// Creates the text to display for the visitor count.
///
static func createVisitorCountText(siteStats: SiteVisitStats?, selectedIntervalIndex: Int?) -> String {
if let visitorCount = visitorCount(at: selectedIntervalIndex, siteStats: siteStats) {
return Double(visitorCount).humanReadableString()
} else {
return Constants.placeholderText
}
}

// MARK: Conversion Stats

/// Creates the text to display for the conversion rate.
///
static func createConversionRateText(orderStats: OrderStatsV4?, siteStats: SiteVisitStats?, selectedIntervalIndex: Int?) -> String {
let visitors = visitorCount(at: selectedIntervalIndex, siteStats: siteStats)
let orders = orderCount(at: selectedIntervalIndex, orderStats: orderStats)

let numberFormatter = NumberFormatter()
numberFormatter.numberStyle = .percent
numberFormatter.minimumFractionDigits = 1

if let visitors, let orders {
// Maximum conversion rate is 100%.
let conversionRate = visitors > 0 ? min(orders/visitors, 1): 0
let minimumFractionDigits = floor(conversionRate * 100.0) == conversionRate * 100.0 ? 0: 1
numberFormatter.minimumFractionDigits = minimumFractionDigits
return numberFormatter.string(from: conversionRate as NSNumber) ?? Constants.placeholderText
} else {
return Constants.placeholderText
}
}

// MARK: Stats Intervals

/// Returns the order stats intervals, ordered by date.
///
static func sortOrderStatsIntervals(from orderStats: OrderStatsV4?) -> [OrderStatsV4Interval] {
return orderStats?.intervals.sorted(by: { (lhs, rhs) -> Bool in
let siteTimezone = TimeZone.siteTimezone
return lhs.dateStart(timeZone: siteTimezone) < rhs.dateStart(timeZone: siteTimezone)
}) ?? []
}
}

// MARK: - Private helpers

private extension StatsDataTextFormatter {
/// Retrieves the visitor count for the provided order stats and, optionally, a specific interval.
///
static func visitorCount(at selectedIndex: Int?, siteStats: SiteVisitStats?) -> Double? {
let siteStatsItems = siteStats?.items?.sorted(by: { (lhs, rhs) -> Bool in
return lhs.period < rhs.period
}) ?? []
if let selectedIndex, selectedIndex < siteStatsItems.count {
return Double(siteStatsItems[selectedIndex].visitors)
} else if let siteStats {
return Double(siteStats.totalVisitors)
} else {
return nil
}
}

/// Retrieves the order count for the provided order stats and, optionally, a specific interval.
///
static func orderCount(at selectedIndex: Int?, orderStats: OrderStatsV4?) -> Double? {
let orderStatsIntervals = sortOrderStatsIntervals(from: orderStats)
if let selectedIndex, selectedIndex < orderStatsIntervals.count {
let orderStats = orderStatsIntervals[selectedIndex]
return Double(orderStats.subtotals.totalOrders)
} else if let orderStats {
return Double(orderStats.totals.totalOrders)
} else {
return nil
}
}

/// Retrieves the average order value for the provided order stats.
///
static func averageOrderValue(orderStats: OrderStatsV4?) -> Decimal? {
if let orderStats {
return orderStats.totals.averageOrderValue
} else {
return nil
}
}

/// Retrieves the total revenue from the provided order stats and, optionally, a specific interval.
///
static func totalRevenue(at selectedIndex: Int?, orderStats: OrderStatsV4?) -> Decimal? {
let orderStatsIntervals = sortOrderStatsIntervals(from: orderStats)
if let selectedIndex, selectedIndex < orderStatsIntervals.count {
let orderStats = orderStatsIntervals[selectedIndex]
return orderStats.subtotals.grossRevenue
} else if let orderStats {
return orderStats.totals.grossRevenue
} else {
return nil
}
}

enum Constants {
static let placeholderText = "-"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,33 +22,38 @@ final class StoreStatsPeriodViewModel {
private(set) lazy var orderStatsText: AnyPublisher<String, Never> =
Publishers.CombineLatest($orderStatsData.eraseToAnyPublisher(), $selectedIntervalIndex.eraseToAnyPublisher())
.compactMap { [weak self] orderStatsData, selectedIntervalIndex in
return self?.createOrderStatsText(orderStatsData: orderStatsData, selectedIntervalIndex: selectedIntervalIndex)
StatsDataTextFormatter.createOrderCountText(orderStats: orderStatsData.stats, selectedIntervalIndex: selectedIntervalIndex)
}
.removeDuplicates()
.eraseToAnyPublisher()

/// Emits revenue stats text values based on order stats, selected time interval, and currency code.
private(set) lazy var revenueStatsText: AnyPublisher<String, Never> = $orderStatsData.combineLatest($selectedIntervalIndex, currencySettings.$currencyCode)
.compactMap { [weak self] orderStatsData, selectedIntervalIndex, currencyCode in
self?.createRevenueStats(orderStatsData: orderStatsData, selectedIntervalIndex: selectedIntervalIndex, currencyCode: currencyCode.rawValue)
StatsDataTextFormatter.createTotalRevenueText(orderStats: orderStatsData.stats,
selectedIntervalIndex: selectedIntervalIndex,
currencyFormatter: self?.currencyFormatter,
currencyCode: currencyCode.rawValue)
}
.removeDuplicates()
.eraseToAnyPublisher()

/// Emits visitor stats text values based on site visit stats and selected time interval.
private(set) lazy var visitorStatsText: AnyPublisher<String, Never> =
Publishers.CombineLatest($siteStats.eraseToAnyPublisher(), $selectedIntervalIndex.eraseToAnyPublisher())
.compactMap { [weak self] siteStats, selectedIntervalIndex in
self?.createVisitorStatsText(siteStats: siteStats, selectedIntervalIndex: selectedIntervalIndex)
.compactMap { siteStats, selectedIntervalIndex in
StatsDataTextFormatter.createVisitorCountText(siteStats: siteStats, selectedIntervalIndex: selectedIntervalIndex)
}
.removeDuplicates()
.eraseToAnyPublisher()

/// Emits conversion stats text values based on order stats, site visit stats, and selected time interval.
private(set) lazy var conversionStatsText: AnyPublisher<String, Never> =
Publishers.CombineLatest3($orderStatsData.eraseToAnyPublisher(), $siteStats.eraseToAnyPublisher(), $selectedIntervalIndex.eraseToAnyPublisher())
.compactMap { [weak self] orderStatsData, siteStats, selectedIntervalIndex in
self?.createConversionStats(orderStatsData: orderStatsData, siteStats: siteStats, selectedIntervalIndex: selectedIntervalIndex)
.compactMap { orderStatsData, siteStats, selectedIntervalIndex in
StatsDataTextFormatter.createConversionRateText(orderStats: orderStatsData.stats,
siteStats: siteStats,
selectedIntervalIndex: selectedIntervalIndex)
}
.removeDuplicates()
.eraseToAnyPublisher()
Expand Down Expand Up @@ -176,51 +181,6 @@ private extension StoreStatsPeriodViewModel {
timezone: siteTimezone)
}

func createOrderStatsText(orderStatsData: OrderStatsData, selectedIntervalIndex: Int?) -> String {
if let count = orderCount(at: selectedIntervalIndex, orderStats: orderStatsData.stats, orderStatsIntervals: orderStatsData.intervals) {
return Double(count).humanReadableString()
} else {
return Constants.placeholderText
}
}

func createRevenueStats(orderStatsData: OrderStatsData, selectedIntervalIndex: Int?, currencyCode: String) -> String {
if let revenue = revenue(at: selectedIntervalIndex, orderStats: orderStatsData.stats, orderStatsIntervals: orderStatsData.intervals) {
// If revenue is an integer, no decimal points are shown.
let numberOfDecimals: Int? = revenue.isInteger ? 0: nil
return currencyFormatter.formatAmount(revenue, with: currencyCode, numberOfDecimals: numberOfDecimals) ?? String()
} else {
return Constants.placeholderText
}
}

func createVisitorStatsText(siteStats: SiteVisitStats?, selectedIntervalIndex: Int?) -> String {
if let visitorCount = visitorCount(at: selectedIntervalIndex, siteStats: siteStats) {
return Double(visitorCount).humanReadableString()
} else {
return Constants.placeholderText
}
}

func createConversionStats(orderStatsData: OrderStatsData, siteStats: SiteVisitStats?, selectedIntervalIndex: Int?) -> String {
let visitors = visitorCount(at: selectedIntervalIndex, siteStats: siteStats)
let orders = orderCount(at: selectedIntervalIndex, orderStats: orderStatsData.stats, orderStatsIntervals: orderStatsData.intervals)

let numberFormatter = NumberFormatter()
numberFormatter.numberStyle = .percent
numberFormatter.minimumFractionDigits = 1

if let visitors = visitors, let orders = orders {
// Maximum conversion rate is 100%.
let conversionRate = visitors > 0 ? min(orders/visitors, 1): 0
let minimumFractionDigits = floor(conversionRate * 100.0) == conversionRate * 100.0 ? 0: 1
numberFormatter.minimumFractionDigits = minimumFractionDigits
return numberFormatter.string(from: conversionRate as NSNumber) ?? Constants.placeholderText
} else {
return Constants.placeholderText
}
}

func visitorStatsViewState(siteVisitStatsMode: SiteVisitStatsMode, selectedIntervalIndex: Int?) -> StoreStatsDataOrRedactedView.State {
switch siteVisitStatsMode {
case .default:
Expand Down Expand Up @@ -272,51 +232,6 @@ private extension StoreStatsPeriodViewModel {
}
}

// MARK: - Private data helpers
//
private extension StoreStatsPeriodViewModel {
func visitorCount(at selectedIndex: Int?, siteStats: SiteVisitStats?) -> Double? {
let siteStatsItems = siteStats?.items?.sorted(by: { (lhs, rhs) -> Bool in
return lhs.period < rhs.period
}) ?? []
if let selectedIndex = selectedIndex, selectedIndex < siteStatsItems.count {
return Double(siteStatsItems[selectedIndex].visitors)
} else if let siteStats = siteStats {
return Double(siteStats.totalVisitors)
} else {
return nil
}
}

func orderCount(at selectedIndex: Int?, orderStats: OrderStatsV4?, orderStatsIntervals: [OrderStatsV4Interval]) -> Double? {
if let selectedIndex = selectedIndex, selectedIndex < orderStatsIntervals.count {
let orderStats = orderStatsIntervals[selectedIndex]
return Double(orderStats.subtotals.totalOrders)
} else if let orderStats = orderStats {
return Double(orderStats.totals.totalOrders)
} else {
return nil
}
}

func revenue(at selectedIndex: Int?, orderStats: OrderStatsV4?, orderStatsIntervals: [OrderStatsV4Interval]) -> Decimal? {
if let selectedIndex = selectedIndex, selectedIndex < orderStatsIntervals.count {
let orderStats = orderStatsIntervals[selectedIndex]
return orderStats.subtotals.grossRevenue
} else if let orderStats = orderStats {
return orderStats.totals.grossRevenue
} else {
return nil
}
}

func orderStatsIntervals(from orderStats: OrderStatsV4?) -> [OrderStatsV4Interval] {
return orderStats?.intervals.sorted(by: { (lhs, rhs) -> Bool in
return lhs.dateStart(timeZone: siteTimezone) < rhs.dateStart(timeZone: siteTimezone)
}) ?? []
}
}

// MARK: - Results controller
//
private extension StoreStatsPeriodViewModel {
Expand Down Expand Up @@ -355,14 +270,13 @@ private extension StoreStatsPeriodViewModel {

func updateOrderDataIfNeeded() {
let orderStats = orderStatsResultsController.fetchedObjects.first
let intervals = orderStatsIntervals(from: orderStats)
let intervals = StatsDataTextFormatter.sortOrderStatsIntervals(from: orderStats)
orderStatsData = (stats: orderStats, intervals: intervals)
}
}

private extension StoreStatsPeriodViewModel {
enum Constants {
static let placeholderText = "-"
static let yAxisMaximumValueWithoutRevenue: Double = 1
static let yAxisMinimumValueWithoutRevenue: Double = -1
}
Expand Down
8 changes: 8 additions & 0 deletions WooCommerce/WooCommerce.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1382,6 +1382,8 @@
C0A37CB8282957EB00E0826D /* orders_3337_add_customer_details.json in Resources */ = {isa = PBXBuildFile; fileRef = C0A37CB7282957EB00E0826D /* orders_3337_add_customer_details.json */; };
C0CE1F84282AB1590019138E /* countries.json in Resources */ = {isa = PBXBuildFile; fileRef = C0CE1F83282AB1590019138E /* countries.json */; };
CC0324A3263AD9F40056C6B7 /* MockShippingLabelAccountSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0324A2263AD9F40056C6B7 /* MockShippingLabelAccountSettings.swift */; };
CC04918D292BB74500F719D8 /* StatsDataTextFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC04918C292BB74500F719D8 /* StatsDataTextFormatter.swift */; };
CC04918F292BD6AC00F719D8 /* StatsDataTextFormatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC04918E292BD6AC00F719D8 /* StatsDataTextFormatterTests.swift */; };
CC078531266E706300BA9AC1 /* ErrorTopBannerFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC078530266E706300BA9AC1 /* ErrorTopBannerFactory.swift */; };
CC07860526736B6500BA9AC1 /* ErrorTopBannerFactoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC07860426736B6500BA9AC1 /* ErrorTopBannerFactoryTests.swift */; };
CC13C0CB278E021300C0B5B5 /* ProductVariationSelectorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC13C0CA278E021300C0B5B5 /* ProductVariationSelectorViewModel.swift */; };
Expand Down Expand Up @@ -3354,6 +3356,8 @@
C0CE1F83282AB1590019138E /* countries.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = countries.json; sourceTree = "<group>"; };
CB4839361AA061340BE98DA9 /* Pods-StoreWidgetsExtension.release-alpha.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-StoreWidgetsExtension.release-alpha.xcconfig"; path = "../Pods/Target Support Files/Pods-StoreWidgetsExtension/Pods-StoreWidgetsExtension.release-alpha.xcconfig"; sourceTree = "<group>"; };
CC0324A2263AD9F40056C6B7 /* MockShippingLabelAccountSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockShippingLabelAccountSettings.swift; sourceTree = "<group>"; };
CC04918C292BB74500F719D8 /* StatsDataTextFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsDataTextFormatter.swift; sourceTree = "<group>"; };
CC04918E292BD6AC00F719D8 /* StatsDataTextFormatterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsDataTextFormatterTests.swift; sourceTree = "<group>"; };
CC078530266E706300BA9AC1 /* ErrorTopBannerFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorTopBannerFactory.swift; sourceTree = "<group>"; };
CC07860426736B6500BA9AC1 /* ErrorTopBannerFactoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorTopBannerFactoryTests.swift; sourceTree = "<group>"; };
CC13C0CA278E021300C0B5B5 /* ProductVariationSelectorViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductVariationSelectorViewModel.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -4774,6 +4778,7 @@
029D444722F13F5C00DEFA8A /* Factories */ = {
isa = PBXGroup;
children = (
CC04918C292BB74500F719D8 /* StatsDataTextFormatter.swift */,
);
path = Factories;
sourceTree = "<group>";
Expand Down Expand Up @@ -4957,6 +4962,7 @@
0257285B230ACC7E00A288C4 /* StoreStatsV4ChartAxisHelperTests.swift */,
02AB40812784297C00929CF3 /* ProductTableViewCellViewModelTests.swift */,
028E1F712833E954001F8829 /* DashboardViewModelTests.swift */,
CC04918E292BD6AC00F719D8 /* StatsDataTextFormatterTests.swift */,
);
path = Dashboard;
sourceTree = "<group>";
Expand Down Expand Up @@ -10543,6 +10549,7 @@
B9DA153C280EC7D700FC67DD /* OrderRefundsOptionsDeterminer.swift in Sources */,
453227B723C4D6EC00D816B3 /* TimeZone+Woo.swift in Sources */,
CC53FB3527551A6E00C4CA4F /* ProductRow.swift in Sources */,
CC04918D292BB74500F719D8 /* StatsDataTextFormatter.swift in Sources */,
2662D90A26E16B3600E25611 /* FilterListSelector.swift in Sources */,
DECE1400279A595200816ECD /* Coupon+Woo.swift in Sources */,
314265B12645A07800500598 /* CardReaderSettingsConnectedViewController.swift in Sources */,
Expand Down Expand Up @@ -11213,6 +11220,7 @@
02B2C831249C4C8D0040C83C /* TextFieldTextAlignmentTests.swift in Sources */,
CC923A1D2847A8E0008EEEBE /* OrderStatusListViewModelTests.swift in Sources */,
D85B833D2230DC9D002168F3 /* StringWooTests.swift in Sources */,
CC04918F292BD6AC00F719D8 /* StatsDataTextFormatterTests.swift in Sources */,
02BC5AA524D27F8900C43326 /* ProductVariationFormViewModel+UpdatesTests.swift in Sources */,
D8736B5122EB69E300A14A29 /* OrderDetailsViewModelTests.swift in Sources */,
02C0CD2E23B5E3AE00F880B1 /* DefaultImageServiceTests.swift in Sources */,
Expand Down
Loading