Skip to content

Commit cc6036e

Browse files
authored
Merge pull request #5788 from woocommerce/feat/5743-refactor-stats-view-model
Refactor `StoreStatsV4PeriodViewController` with a view model
2 parents f88ed22 + 97a80ca commit cc6036e

File tree

5 files changed

+395
-308
lines changed

5 files changed

+395
-308
lines changed

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

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,7 @@ final class StoreStatsAndTopPerformersPeriodViewController: UIViewController {
3232
var onPullToRefresh: () -> Void = {}
3333

3434
/// Updated when reloading data.
35-
var currentDate: Date {
36-
didSet {
37-
storeStatsPeriodViewController.currentDate = currentDate
38-
}
39-
}
35+
var currentDate: Date
4036

4137
/// Updated when reloading data.
4238
var siteTimezone: TimeZone = .current {
@@ -81,7 +77,7 @@ final class StoreStatsAndTopPerformersPeriodViewController: UIViewController {
8177
// MARK: Child View Controllers
8278

8379
private lazy var storeStatsPeriodViewController: StoreStatsV4PeriodViewController = {
84-
return StoreStatsV4PeriodViewController(timeRange: timeRange, currentDate: currentDate)
80+
return StoreStatsV4PeriodViewController(siteID: siteID, timeRange: timeRange)
8581
}()
8682

8783
private lazy var inAppFeedbackCardViewController = InAppFeedbackCardViewController()
Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
import Combine
2+
import protocol Storage.StorageManagerType
3+
import Yosemite
4+
import Foundation
5+
6+
/// Provides data and observables for UI in `StoreStatsV4PeriodViewController`.
7+
final class StoreStatsPeriodViewModel {
8+
// MARK: - Public data & observables
9+
10+
/// Used for chart updates.
11+
var orderStatsIntervals: [OrderStatsV4Interval] {
12+
orderStatsData.intervals
13+
}
14+
15+
/// Updated externally from user interactions with the chart.
16+
@Published var selectedIntervalIndex: Int? = nil
17+
18+
/// Emits order stats text values based on order stats and selected time interval.
19+
private(set) lazy var orderStatsText: AnyPublisher<String, Never> =
20+
Publishers.CombineLatest($orderStatsData.eraseToAnyPublisher(), $selectedIntervalIndex.eraseToAnyPublisher())
21+
.compactMap { [weak self] orderStatsData, selectedIntervalIndex in
22+
return self?.createOrderStatsText(orderStatsData: orderStatsData, selectedIntervalIndex: selectedIntervalIndex)
23+
}
24+
.removeDuplicates()
25+
.eraseToAnyPublisher()
26+
27+
/// Emits revenue stats text values based on order stats and selected time interval.
28+
private(set) lazy var revenueStatsText: AnyPublisher<String, Never> =
29+
Publishers.CombineLatest($orderStatsData.eraseToAnyPublisher(), $selectedIntervalIndex.eraseToAnyPublisher())
30+
.compactMap { [weak self] orderStatsData, selectedIntervalIndex in
31+
self?.createRevenueStats(orderStatsData: orderStatsData, selectedIntervalIndex: selectedIntervalIndex)
32+
}
33+
.removeDuplicates()
34+
.eraseToAnyPublisher()
35+
36+
/// Emits visitor stats text values based on site visit stats and selected time interval.
37+
private(set) lazy var visitorStatsText: AnyPublisher<String, Never> =
38+
Publishers.CombineLatest($siteStats.eraseToAnyPublisher(), $selectedIntervalIndex.eraseToAnyPublisher())
39+
.compactMap { [weak self] siteStats, selectedIntervalIndex in
40+
self?.createVisitorStatsText(siteStats: siteStats, selectedIntervalIndex: selectedIntervalIndex)
41+
}
42+
.removeDuplicates()
43+
.eraseToAnyPublisher()
44+
45+
/// Emits conversion stats text values based on order stats, site visit stats, and selected time interval.
46+
private(set) lazy var conversionStatsText: AnyPublisher<String, Never> =
47+
Publishers.CombineLatest3($orderStatsData.eraseToAnyPublisher(), $siteStats.eraseToAnyPublisher(), $selectedIntervalIndex.eraseToAnyPublisher())
48+
.compactMap { [weak self] orderStatsData, siteStats, selectedIntervalIndex in
49+
self?.createConversionStats(orderStatsData: orderStatsData, siteStats: siteStats, selectedIntervalIndex: selectedIntervalIndex)
50+
}
51+
.removeDuplicates()
52+
.eraseToAnyPublisher()
53+
54+
/// Emits view models for time range bar that shows the time range for the selected time interval.
55+
private(set) lazy var timeRangeBarViewModel: AnyPublisher<StatsTimeRangeBarViewModel, Never> =
56+
Publishers.CombineLatest($orderStatsData.eraseToAnyPublisher(), $selectedIntervalIndex.eraseToAnyPublisher())
57+
.compactMap { [weak self] orderStatsData, selectedIntervalIndex in
58+
return self?.createTimeRangeBarViewModel(orderStatsData: orderStatsData, selectedIntervalIndex: selectedIntervalIndex)
59+
}
60+
.eraseToAnyPublisher()
61+
62+
/// Emits a boolean to reload chart, and the boolean indicates whether the reload should be animated.
63+
var reloadChartAnimated: AnyPublisher<Bool, Never> {
64+
shouldReloadChartAnimated.eraseToAnyPublisher()
65+
}
66+
67+
// MARK: - Private data
68+
69+
@Published private var siteStats: SiteVisitStats?
70+
71+
typealias OrderStatsData = (stats: OrderStatsV4?, intervals: [OrderStatsV4Interval])
72+
@Published private var orderStatsData: OrderStatsData = (nil, [])
73+
74+
private let shouldReloadChartAnimated: PassthroughSubject<Bool, Never> = .init()
75+
76+
// MARK: - Results controllers
77+
78+
/// SiteVisitStats ResultsController: Loads site visit stats from the Storage Layer
79+
private lazy var siteStatsResultsController: ResultsController<StorageSiteVisitStats> = {
80+
let predicate = NSPredicate(format: "siteID = %ld AND granularity ==[c] %@ AND timeRange == %@",
81+
siteID,
82+
timeRange.siteVisitStatsGranularity.rawValue,
83+
timeRange.rawValue)
84+
let descriptor = NSSortDescriptor(keyPath: \StorageSiteVisitStats.date, ascending: false)
85+
return ResultsController(storageManager: storageManager, matching: predicate, sortedBy: [descriptor])
86+
}()
87+
88+
/// OrderStats ResultsController: Loads order stats from the Storage Layer
89+
private lazy var orderStatsResultsController: ResultsController<StorageOrderStatsV4> = {
90+
let predicate = NSPredicate(format: "siteID = %ld AND timeRange ==[c] %@", siteID, timeRange.rawValue)
91+
return ResultsController(storageManager: storageManager, matching: predicate, sortedBy: [])
92+
}()
93+
94+
// MARK: - Configurations
95+
96+
/// Updated externally when reloading data.
97+
var siteTimezone: TimeZone
98+
99+
private let siteID: Int64
100+
private let timeRange: StatsTimeRangeV4
101+
private let currencyFormatter: CurrencyFormatter
102+
private let currencyCode: String
103+
private let storageManager: StorageManagerType
104+
105+
private var cancellables: Set<AnyCancellable> = []
106+
107+
init(siteID: Int64,
108+
timeRange: StatsTimeRangeV4,
109+
siteTimezone: TimeZone,
110+
currencyFormatter: CurrencyFormatter,
111+
currencyCode: String,
112+
storageManager: StorageManagerType = ServiceLocator.storageManager) {
113+
self.siteID = siteID
114+
self.timeRange = timeRange
115+
self.siteTimezone = siteTimezone
116+
self.currencyFormatter = currencyFormatter
117+
self.currencyCode = currencyCode
118+
self.storageManager = storageManager
119+
120+
// Make sure the ResultsControllers are ready to observe changes to the data even before the view loads
121+
configureResultsControllers()
122+
}
123+
}
124+
125+
// MARK: Private helpers for public data calculation
126+
//
127+
private extension StoreStatsPeriodViewModel {
128+
func createTimeRangeBarViewModel(orderStatsData: OrderStatsData, selectedIntervalIndex: Int?) -> StatsTimeRangeBarViewModel? {
129+
let orderStatsIntervals = orderStatsData.intervals
130+
guard let startDate = orderStatsIntervals.first?.dateStart(timeZone: self.siteTimezone),
131+
let endDate = orderStatsIntervals.last?.dateStart(timeZone: self.siteTimezone) else {
132+
return nil
133+
}
134+
guard let selectedIndex = selectedIntervalIndex else {
135+
return StatsTimeRangeBarViewModel(startDate: startDate,
136+
endDate: endDate,
137+
timeRange: timeRange,
138+
timezone: siteTimezone)
139+
}
140+
let date = orderStatsIntervals[selectedIndex].dateStart(timeZone: siteTimezone)
141+
return StatsTimeRangeBarViewModel(startDate: startDate,
142+
endDate: endDate,
143+
selectedDate: date,
144+
timeRange: timeRange,
145+
timezone: siteTimezone)
146+
}
147+
148+
func createOrderStatsText(orderStatsData: OrderStatsData, selectedIntervalIndex: Int?) -> String {
149+
if let count = orderCount(at: selectedIntervalIndex, orderStats: orderStatsData.stats, orderStatsIntervals: orderStatsData.intervals) {
150+
return Double(count).humanReadableString()
151+
} else {
152+
return Constants.placeholderText
153+
}
154+
}
155+
156+
func createRevenueStats(orderStatsData: OrderStatsData, selectedIntervalIndex: Int?) -> String {
157+
if let revenue = revenue(at: selectedIntervalIndex, orderStats: orderStatsData.stats, orderStatsIntervals: orderStatsData.intervals) {
158+
return currencyFormatter.formatHumanReadableAmount(String("\(revenue)"), with: currencyCode) ?? String()
159+
} else {
160+
return Constants.placeholderText
161+
}
162+
}
163+
164+
func createVisitorStatsText(siteStats: SiteVisitStats?, selectedIntervalIndex: Int?) -> String {
165+
if let visitorCount = visitorCount(at: selectedIntervalIndex, siteStats: siteStats) {
166+
return Double(visitorCount).humanReadableString()
167+
} else {
168+
return Constants.placeholderText
169+
}
170+
}
171+
172+
func createConversionStats(orderStatsData: OrderStatsData, siteStats: SiteVisitStats?, selectedIntervalIndex: Int?) -> String {
173+
let visitors = visitorCount(at: selectedIntervalIndex, siteStats: siteStats)
174+
let orders = orderCount(at: selectedIntervalIndex, orderStats: orderStatsData.stats, orderStatsIntervals: orderStatsData.intervals)
175+
if let visitors = visitors, let orders = orders, visitors > 0 {
176+
// Maximum conversion rate is 100%.
177+
let conversionRate = min(orders/visitors, 1)
178+
let numberFormatter = NumberFormatter()
179+
numberFormatter.numberStyle = .percent
180+
numberFormatter.minimumFractionDigits = 1
181+
return numberFormatter.string(from: conversionRate as NSNumber) ?? Constants.placeholderText
182+
} else {
183+
return Constants.placeholderText
184+
}
185+
}
186+
}
187+
188+
// MARK: - Private data helpers
189+
//
190+
private extension StoreStatsPeriodViewModel {
191+
func visitorCount(at selectedIndex: Int?, siteStats: SiteVisitStats?) -> Double? {
192+
let siteStatsItems = siteStats?.items?.sorted(by: { (lhs, rhs) -> Bool in
193+
return lhs.period < rhs.period
194+
}) ?? []
195+
if let selectedIndex = selectedIndex, selectedIndex < siteStatsItems.count {
196+
return Double(siteStatsItems[selectedIndex].visitors)
197+
} else if let siteStats = siteStats {
198+
return Double(siteStats.totalVisitors)
199+
} else {
200+
return nil
201+
}
202+
}
203+
204+
func orderCount(at selectedIndex: Int?, orderStats: OrderStatsV4?, orderStatsIntervals: [OrderStatsV4Interval]) -> Double? {
205+
if let selectedIndex = selectedIndex, selectedIndex < orderStatsIntervals.count {
206+
let orderStats = orderStatsIntervals[selectedIndex]
207+
return Double(orderStats.subtotals.totalOrders)
208+
} else if let orderStats = orderStats {
209+
return Double(orderStats.totals.totalOrders)
210+
} else {
211+
return nil
212+
}
213+
}
214+
215+
func revenue(at selectedIndex: Int?, orderStats: OrderStatsV4?, orderStatsIntervals: [OrderStatsV4Interval]) -> Decimal? {
216+
if let selectedIndex = selectedIndex, selectedIndex < orderStatsIntervals.count {
217+
let orderStats = orderStatsIntervals[selectedIndex]
218+
return orderStats.subtotals.grossRevenue
219+
} else if let orderStats = orderStats {
220+
return orderStats.totals.grossRevenue
221+
} else {
222+
return nil
223+
}
224+
}
225+
226+
func orderStatsIntervals(from orderStats: OrderStatsV4?) -> [OrderStatsV4Interval] {
227+
return orderStats?.intervals.sorted(by: { (lhs, rhs) -> Bool in
228+
return lhs.dateStart(timeZone: siteTimezone) < rhs.dateStart(timeZone: siteTimezone)
229+
}) ?? []
230+
}
231+
}
232+
233+
// MARK: - Results controller
234+
//
235+
private extension StoreStatsPeriodViewModel {
236+
func configureResultsControllers() {
237+
configureSiteStatsResultsController()
238+
configureOrderStatsResultsController()
239+
}
240+
241+
func configureOrderStatsResultsController() {
242+
orderStatsResultsController.onDidChangeContent = { [weak self] in
243+
self?.updateOrderDataIfNeeded()
244+
}
245+
orderStatsResultsController.onDidResetContent = { [weak self] in
246+
self?.updateOrderDataIfNeeded()
247+
}
248+
try? orderStatsResultsController.performFetch()
249+
}
250+
251+
func configureSiteStatsResultsController() {
252+
siteStatsResultsController.onDidChangeContent = { [weak self] in
253+
self?.updateSiteVisitDataIfNeeded()
254+
}
255+
siteStatsResultsController.onDidResetContent = { [weak self] in
256+
self?.updateSiteVisitDataIfNeeded()
257+
}
258+
try? siteStatsResultsController.performFetch()
259+
}
260+
}
261+
262+
// MARK: - Private Helpers
263+
//
264+
private extension StoreStatsPeriodViewModel {
265+
func updateSiteVisitDataIfNeeded() {
266+
siteStats = siteStatsResultsController.fetchedObjects.first
267+
}
268+
269+
func updateOrderDataIfNeeded() {
270+
let orderStats = orderStatsResultsController.fetchedObjects.first
271+
let intervals = orderStatsIntervals(from: orderStats)
272+
orderStatsData = (stats: orderStats, intervals: intervals)
273+
274+
// Don't animate the chart here - this helps avoid a "double animation" effect if a
275+
// small number of values change (the chart WILL be updated correctly however)
276+
shouldReloadChartAnimated.send(false)
277+
}
278+
}
279+
280+
private extension StoreStatsPeriodViewModel {
281+
enum Constants {
282+
static let placeholderText = "-"
283+
}
284+
}

0 commit comments

Comments
 (0)