Skip to content

Commit 4b4dfa8

Browse files
authored
Merge pull request #8175 from woocommerce/issue/8149-shared-stats-helpers
[Analytics Hub] Create shared set of stats helpers for My Store and Analytics Hub
2 parents ff83786 + d6d6c39 commit 4b4dfa8

File tree

5 files changed

+380
-297
lines changed

5 files changed

+380
-297
lines changed
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import Foundation
2+
import Yosemite
3+
import WooFoundation
4+
5+
/// Helpers for calculating and formatting stats data for display.
6+
///
7+
struct StatsDataTextFormatter {
8+
9+
// MARK: Revenue Stats
10+
11+
/// Creates the text to display for the total revenue.
12+
///
13+
static func createTotalRevenueText(orderStats: OrderStatsV4?,
14+
selectedIntervalIndex: Int?,
15+
currencyFormatter: CurrencyFormatter?,
16+
currencyCode: String) -> String {
17+
if let revenue = totalRevenue(at: selectedIntervalIndex, orderStats: orderStats) {
18+
// If revenue is an integer, no decimal points are shown.
19+
let numberOfDecimals: Int? = revenue.isInteger ? 0: nil
20+
let currencyFormatter = currencyFormatter ?? CurrencyFormatter(currencySettings: ServiceLocator.currencySettings)
21+
return currencyFormatter.formatAmount(revenue, with: currencyCode, numberOfDecimals: numberOfDecimals) ?? String()
22+
} else {
23+
return Constants.placeholderText
24+
}
25+
}
26+
27+
// MARK: Orders Stats
28+
29+
/// Creates the text to display for the order count.
30+
///
31+
static func createOrderCountText(orderStats: OrderStatsV4?, selectedIntervalIndex: Int?) -> String {
32+
if let count = orderCount(at: selectedIntervalIndex, orderStats: orderStats) {
33+
return Double(count).humanReadableString()
34+
} else {
35+
return Constants.placeholderText
36+
}
37+
}
38+
39+
/// Creates the text to display for the average order value.
40+
///
41+
static func createAverageOrderValueText(orderStats: OrderStatsV4?, currencyFormatter: CurrencyFormatter, currencyCode: String) -> String {
42+
if let value = averageOrderValue(orderStats: orderStats) {
43+
// If order value is an integer, no decimal points are shown.
44+
let numberOfDecimals: Int? = value.isInteger ? 0 : nil
45+
return currencyFormatter.formatAmount(value, with: currencyCode, numberOfDecimals: numberOfDecimals) ?? String()
46+
} else {
47+
return Constants.placeholderText
48+
}
49+
}
50+
51+
// MARK: Views and Visitors Stats
52+
53+
/// Creates the text to display for the visitor count.
54+
///
55+
static func createVisitorCountText(siteStats: SiteVisitStats?, selectedIntervalIndex: Int?) -> String {
56+
if let visitorCount = visitorCount(at: selectedIntervalIndex, siteStats: siteStats) {
57+
return Double(visitorCount).humanReadableString()
58+
} else {
59+
return Constants.placeholderText
60+
}
61+
}
62+
63+
// MARK: Conversion Stats
64+
65+
/// Creates the text to display for the conversion rate.
66+
///
67+
static func createConversionRateText(orderStats: OrderStatsV4?, siteStats: SiteVisitStats?, selectedIntervalIndex: Int?) -> String {
68+
let visitors = visitorCount(at: selectedIntervalIndex, siteStats: siteStats)
69+
let orders = orderCount(at: selectedIntervalIndex, orderStats: orderStats)
70+
71+
let numberFormatter = NumberFormatter()
72+
numberFormatter.numberStyle = .percent
73+
numberFormatter.minimumFractionDigits = 1
74+
75+
if let visitors, let orders {
76+
// Maximum conversion rate is 100%.
77+
let conversionRate = visitors > 0 ? min(orders/visitors, 1): 0
78+
let minimumFractionDigits = floor(conversionRate * 100.0) == conversionRate * 100.0 ? 0: 1
79+
numberFormatter.minimumFractionDigits = minimumFractionDigits
80+
return numberFormatter.string(from: conversionRate as NSNumber) ?? Constants.placeholderText
81+
} else {
82+
return Constants.placeholderText
83+
}
84+
}
85+
86+
// MARK: Stats Intervals
87+
88+
/// Returns the order stats intervals, ordered by date.
89+
///
90+
static func sortOrderStatsIntervals(from orderStats: OrderStatsV4?) -> [OrderStatsV4Interval] {
91+
return orderStats?.intervals.sorted(by: { (lhs, rhs) -> Bool in
92+
let siteTimezone = TimeZone.siteTimezone
93+
return lhs.dateStart(timeZone: siteTimezone) < rhs.dateStart(timeZone: siteTimezone)
94+
}) ?? []
95+
}
96+
}
97+
98+
// MARK: - Private helpers
99+
100+
private extension StatsDataTextFormatter {
101+
/// Retrieves the visitor count for the provided order stats and, optionally, a specific interval.
102+
///
103+
static func visitorCount(at selectedIndex: Int?, siteStats: SiteVisitStats?) -> Double? {
104+
let siteStatsItems = siteStats?.items?.sorted(by: { (lhs, rhs) -> Bool in
105+
return lhs.period < rhs.period
106+
}) ?? []
107+
if let selectedIndex, selectedIndex < siteStatsItems.count {
108+
return Double(siteStatsItems[selectedIndex].visitors)
109+
} else if let siteStats {
110+
return Double(siteStats.totalVisitors)
111+
} else {
112+
return nil
113+
}
114+
}
115+
116+
/// Retrieves the order count for the provided order stats and, optionally, a specific interval.
117+
///
118+
static func orderCount(at selectedIndex: Int?, orderStats: OrderStatsV4?) -> Double? {
119+
let orderStatsIntervals = sortOrderStatsIntervals(from: orderStats)
120+
if let selectedIndex, selectedIndex < orderStatsIntervals.count {
121+
let orderStats = orderStatsIntervals[selectedIndex]
122+
return Double(orderStats.subtotals.totalOrders)
123+
} else if let orderStats {
124+
return Double(orderStats.totals.totalOrders)
125+
} else {
126+
return nil
127+
}
128+
}
129+
130+
/// Retrieves the average order value for the provided order stats.
131+
///
132+
static func averageOrderValue(orderStats: OrderStatsV4?) -> Decimal? {
133+
if let orderStats {
134+
return orderStats.totals.averageOrderValue
135+
} else {
136+
return nil
137+
}
138+
}
139+
140+
/// Retrieves the total revenue from the provided order stats and, optionally, a specific interval.
141+
///
142+
static func totalRevenue(at selectedIndex: Int?, orderStats: OrderStatsV4?) -> Decimal? {
143+
let orderStatsIntervals = sortOrderStatsIntervals(from: orderStats)
144+
if let selectedIndex, selectedIndex < orderStatsIntervals.count {
145+
let orderStats = orderStatsIntervals[selectedIndex]
146+
return orderStats.subtotals.grossRevenue
147+
} else if let orderStats {
148+
return orderStats.totals.grossRevenue
149+
} else {
150+
return nil
151+
}
152+
}
153+
154+
enum Constants {
155+
static let placeholderText = "-"
156+
}
157+
}

WooCommerce/Classes/ViewRelated/Dashboard/Stats v4/StoreStatsPeriodViewModel.swift

Lines changed: 12 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -22,33 +22,38 @@ final class StoreStatsPeriodViewModel {
2222
private(set) lazy var orderStatsText: AnyPublisher<String, Never> =
2323
Publishers.CombineLatest($orderStatsData.eraseToAnyPublisher(), $selectedIntervalIndex.eraseToAnyPublisher())
2424
.compactMap { [weak self] orderStatsData, selectedIntervalIndex in
25-
return self?.createOrderStatsText(orderStatsData: orderStatsData, selectedIntervalIndex: selectedIntervalIndex)
25+
StatsDataTextFormatter.createOrderCountText(orderStats: orderStatsData.stats, selectedIntervalIndex: selectedIntervalIndex)
2626
}
2727
.removeDuplicates()
2828
.eraseToAnyPublisher()
2929

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

3841
/// Emits visitor stats text values based on site visit stats and selected time interval.
3942
private(set) lazy var visitorStatsText: AnyPublisher<String, Never> =
4043
Publishers.CombineLatest($siteStats.eraseToAnyPublisher(), $selectedIntervalIndex.eraseToAnyPublisher())
41-
.compactMap { [weak self] siteStats, selectedIntervalIndex in
42-
self?.createVisitorStatsText(siteStats: siteStats, selectedIntervalIndex: selectedIntervalIndex)
44+
.compactMap { siteStats, selectedIntervalIndex in
45+
StatsDataTextFormatter.createVisitorCountText(siteStats: siteStats, selectedIntervalIndex: selectedIntervalIndex)
4346
}
4447
.removeDuplicates()
4548
.eraseToAnyPublisher()
4649

4750
/// Emits conversion stats text values based on order stats, site visit stats, and selected time interval.
4851
private(set) lazy var conversionStatsText: AnyPublisher<String, Never> =
4952
Publishers.CombineLatest3($orderStatsData.eraseToAnyPublisher(), $siteStats.eraseToAnyPublisher(), $selectedIntervalIndex.eraseToAnyPublisher())
50-
.compactMap { [weak self] orderStatsData, siteStats, selectedIntervalIndex in
51-
self?.createConversionStats(orderStatsData: orderStatsData, siteStats: siteStats, selectedIntervalIndex: selectedIntervalIndex)
53+
.compactMap { orderStatsData, siteStats, selectedIntervalIndex in
54+
StatsDataTextFormatter.createConversionRateText(orderStats: orderStatsData.stats,
55+
siteStats: siteStats,
56+
selectedIntervalIndex: selectedIntervalIndex)
5257
}
5358
.removeDuplicates()
5459
.eraseToAnyPublisher()
@@ -176,51 +181,6 @@ private extension StoreStatsPeriodViewModel {
176181
timezone: siteTimezone)
177182
}
178183

179-
func createOrderStatsText(orderStatsData: OrderStatsData, selectedIntervalIndex: Int?) -> String {
180-
if let count = orderCount(at: selectedIntervalIndex, orderStats: orderStatsData.stats, orderStatsIntervals: orderStatsData.intervals) {
181-
return Double(count).humanReadableString()
182-
} else {
183-
return Constants.placeholderText
184-
}
185-
}
186-
187-
func createRevenueStats(orderStatsData: OrderStatsData, selectedIntervalIndex: Int?, currencyCode: String) -> String {
188-
if let revenue = revenue(at: selectedIntervalIndex, orderStats: orderStatsData.stats, orderStatsIntervals: orderStatsData.intervals) {
189-
// If revenue is an integer, no decimal points are shown.
190-
let numberOfDecimals: Int? = revenue.isInteger ? 0: nil
191-
return currencyFormatter.formatAmount(revenue, with: currencyCode, numberOfDecimals: numberOfDecimals) ?? String()
192-
} else {
193-
return Constants.placeholderText
194-
}
195-
}
196-
197-
func createVisitorStatsText(siteStats: SiteVisitStats?, selectedIntervalIndex: Int?) -> String {
198-
if let visitorCount = visitorCount(at: selectedIntervalIndex, siteStats: siteStats) {
199-
return Double(visitorCount).humanReadableString()
200-
} else {
201-
return Constants.placeholderText
202-
}
203-
}
204-
205-
func createConversionStats(orderStatsData: OrderStatsData, siteStats: SiteVisitStats?, selectedIntervalIndex: Int?) -> String {
206-
let visitors = visitorCount(at: selectedIntervalIndex, siteStats: siteStats)
207-
let orders = orderCount(at: selectedIntervalIndex, orderStats: orderStatsData.stats, orderStatsIntervals: orderStatsData.intervals)
208-
209-
let numberFormatter = NumberFormatter()
210-
numberFormatter.numberStyle = .percent
211-
numberFormatter.minimumFractionDigits = 1
212-
213-
if let visitors = visitors, let orders = orders {
214-
// Maximum conversion rate is 100%.
215-
let conversionRate = visitors > 0 ? min(orders/visitors, 1): 0
216-
let minimumFractionDigits = floor(conversionRate * 100.0) == conversionRate * 100.0 ? 0: 1
217-
numberFormatter.minimumFractionDigits = minimumFractionDigits
218-
return numberFormatter.string(from: conversionRate as NSNumber) ?? Constants.placeholderText
219-
} else {
220-
return Constants.placeholderText
221-
}
222-
}
223-
224184
func visitorStatsViewState(siteVisitStatsMode: SiteVisitStatsMode, selectedIntervalIndex: Int?) -> StoreStatsDataOrRedactedView.State {
225185
switch siteVisitStatsMode {
226186
case .default:
@@ -272,51 +232,6 @@ private extension StoreStatsPeriodViewModel {
272232
}
273233
}
274234

275-
// MARK: - Private data helpers
276-
//
277-
private extension StoreStatsPeriodViewModel {
278-
func visitorCount(at selectedIndex: Int?, siteStats: SiteVisitStats?) -> Double? {
279-
let siteStatsItems = siteStats?.items?.sorted(by: { (lhs, rhs) -> Bool in
280-
return lhs.period < rhs.period
281-
}) ?? []
282-
if let selectedIndex = selectedIndex, selectedIndex < siteStatsItems.count {
283-
return Double(siteStatsItems[selectedIndex].visitors)
284-
} else if let siteStats = siteStats {
285-
return Double(siteStats.totalVisitors)
286-
} else {
287-
return nil
288-
}
289-
}
290-
291-
func orderCount(at selectedIndex: Int?, orderStats: OrderStatsV4?, orderStatsIntervals: [OrderStatsV4Interval]) -> Double? {
292-
if let selectedIndex = selectedIndex, selectedIndex < orderStatsIntervals.count {
293-
let orderStats = orderStatsIntervals[selectedIndex]
294-
return Double(orderStats.subtotals.totalOrders)
295-
} else if let orderStats = orderStats {
296-
return Double(orderStats.totals.totalOrders)
297-
} else {
298-
return nil
299-
}
300-
}
301-
302-
func revenue(at selectedIndex: Int?, orderStats: OrderStatsV4?, orderStatsIntervals: [OrderStatsV4Interval]) -> Decimal? {
303-
if let selectedIndex = selectedIndex, selectedIndex < orderStatsIntervals.count {
304-
let orderStats = orderStatsIntervals[selectedIndex]
305-
return orderStats.subtotals.grossRevenue
306-
} else if let orderStats = orderStats {
307-
return orderStats.totals.grossRevenue
308-
} else {
309-
return nil
310-
}
311-
}
312-
313-
func orderStatsIntervals(from orderStats: OrderStatsV4?) -> [OrderStatsV4Interval] {
314-
return orderStats?.intervals.sorted(by: { (lhs, rhs) -> Bool in
315-
return lhs.dateStart(timeZone: siteTimezone) < rhs.dateStart(timeZone: siteTimezone)
316-
}) ?? []
317-
}
318-
}
319-
320235
// MARK: - Results controller
321236
//
322237
private extension StoreStatsPeriodViewModel {
@@ -355,14 +270,13 @@ private extension StoreStatsPeriodViewModel {
355270

356271
func updateOrderDataIfNeeded() {
357272
let orderStats = orderStatsResultsController.fetchedObjects.first
358-
let intervals = orderStatsIntervals(from: orderStats)
273+
let intervals = StatsDataTextFormatter.sortOrderStatsIntervals(from: orderStats)
359274
orderStatsData = (stats: orderStats, intervals: intervals)
360275
}
361276
}
362277

363278
private extension StoreStatsPeriodViewModel {
364279
enum Constants {
365-
static let placeholderText = "-"
366280
static let yAxisMaximumValueWithoutRevenue: Double = 1
367281
static let yAxisMinimumValueWithoutRevenue: Double = -1
368282
}

WooCommerce/WooCommerce.xcodeproj/project.pbxproj

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1382,6 +1382,8 @@
13821382
C0A37CB8282957EB00E0826D /* orders_3337_add_customer_details.json in Resources */ = {isa = PBXBuildFile; fileRef = C0A37CB7282957EB00E0826D /* orders_3337_add_customer_details.json */; };
13831383
C0CE1F84282AB1590019138E /* countries.json in Resources */ = {isa = PBXBuildFile; fileRef = C0CE1F83282AB1590019138E /* countries.json */; };
13841384
CC0324A3263AD9F40056C6B7 /* MockShippingLabelAccountSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0324A2263AD9F40056C6B7 /* MockShippingLabelAccountSettings.swift */; };
1385+
CC04918D292BB74500F719D8 /* StatsDataTextFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC04918C292BB74500F719D8 /* StatsDataTextFormatter.swift */; };
1386+
CC04918F292BD6AC00F719D8 /* StatsDataTextFormatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC04918E292BD6AC00F719D8 /* StatsDataTextFormatterTests.swift */; };
13851387
CC078531266E706300BA9AC1 /* ErrorTopBannerFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC078530266E706300BA9AC1 /* ErrorTopBannerFactory.swift */; };
13861388
CC07860526736B6500BA9AC1 /* ErrorTopBannerFactoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC07860426736B6500BA9AC1 /* ErrorTopBannerFactoryTests.swift */; };
13871389
CC13C0CB278E021300C0B5B5 /* ProductVariationSelectorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC13C0CA278E021300C0B5B5 /* ProductVariationSelectorViewModel.swift */; };
@@ -3354,6 +3356,8 @@
33543356
C0CE1F83282AB1590019138E /* countries.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = countries.json; sourceTree = "<group>"; };
33553357
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>"; };
33563358
CC0324A2263AD9F40056C6B7 /* MockShippingLabelAccountSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockShippingLabelAccountSettings.swift; sourceTree = "<group>"; };
3359+
CC04918C292BB74500F719D8 /* StatsDataTextFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsDataTextFormatter.swift; sourceTree = "<group>"; };
3360+
CC04918E292BD6AC00F719D8 /* StatsDataTextFormatterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsDataTextFormatterTests.swift; sourceTree = "<group>"; };
33573361
CC078530266E706300BA9AC1 /* ErrorTopBannerFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorTopBannerFactory.swift; sourceTree = "<group>"; };
33583362
CC07860426736B6500BA9AC1 /* ErrorTopBannerFactoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorTopBannerFactoryTests.swift; sourceTree = "<group>"; };
33593363
CC13C0CA278E021300C0B5B5 /* ProductVariationSelectorViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductVariationSelectorViewModel.swift; sourceTree = "<group>"; };
@@ -4774,6 +4778,7 @@
47744778
029D444722F13F5C00DEFA8A /* Factories */ = {
47754779
isa = PBXGroup;
47764780
children = (
4781+
CC04918C292BB74500F719D8 /* StatsDataTextFormatter.swift */,
47774782
);
47784783
path = Factories;
47794784
sourceTree = "<group>";
@@ -4957,6 +4962,7 @@
49574962
0257285B230ACC7E00A288C4 /* StoreStatsV4ChartAxisHelperTests.swift */,
49584963
02AB40812784297C00929CF3 /* ProductTableViewCellViewModelTests.swift */,
49594964
028E1F712833E954001F8829 /* DashboardViewModelTests.swift */,
4965+
CC04918E292BD6AC00F719D8 /* StatsDataTextFormatterTests.swift */,
49604966
);
49614967
path = Dashboard;
49624968
sourceTree = "<group>";
@@ -10543,6 +10549,7 @@
1054310549
B9DA153C280EC7D700FC67DD /* OrderRefundsOptionsDeterminer.swift in Sources */,
1054410550
453227B723C4D6EC00D816B3 /* TimeZone+Woo.swift in Sources */,
1054510551
CC53FB3527551A6E00C4CA4F /* ProductRow.swift in Sources */,
10552+
CC04918D292BB74500F719D8 /* StatsDataTextFormatter.swift in Sources */,
1054610553
2662D90A26E16B3600E25611 /* FilterListSelector.swift in Sources */,
1054710554
DECE1400279A595200816ECD /* Coupon+Woo.swift in Sources */,
1054810555
314265B12645A07800500598 /* CardReaderSettingsConnectedViewController.swift in Sources */,
@@ -11213,6 +11220,7 @@
1121311220
02B2C831249C4C8D0040C83C /* TextFieldTextAlignmentTests.swift in Sources */,
1121411221
CC923A1D2847A8E0008EEEBE /* OrderStatusListViewModelTests.swift in Sources */,
1121511222
D85B833D2230DC9D002168F3 /* StringWooTests.swift in Sources */,
11223+
CC04918F292BD6AC00F719D8 /* StatsDataTextFormatterTests.swift in Sources */,
1121611224
02BC5AA524D27F8900C43326 /* ProductVariationFormViewModel+UpdatesTests.swift in Sources */,
1121711225
D8736B5122EB69E300A14A29 /* OrderDetailsViewModelTests.swift in Sources */,
1121811226
02C0CD2E23B5E3AE00F880B1 /* DefaultImageServiceTests.swift in Sources */,

0 commit comments

Comments
 (0)