Skip to content

Commit 411a4f8

Browse files
authored
Merge branch 'trunk' into issue/8324-release-sessions-card
2 parents 603e7fe + d71c1cf commit 411a4f8

13 files changed

+222
-78
lines changed

RELEASE-NOTES.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
-----
55
- [**] Analytics Hub: Now you can select custom date ranges. [https://github.com/woocommerce/woocommerce-ios/pull/8414]
66
- [**] Analytics Hub: Now you can see Views and Conversion Rate analytics in the new Sessions card. [https://github.com/woocommerce/woocommerce-ios/pull/8428]
7+
- [*] My Store: We fixed an issue with Visitors and Conversion stats where sometimes visitors could be counted more than once in the selected period. [https://github.com/woocommerce/woocommerce-ios/pull/8427]
78

89

910
11.6

WooCommerce/Classes/ViewRelated/Dashboard/DashboardViewModel.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,26 @@ final class DashboardViewModel {
8888
stores.dispatch(action)
8989
}
9090

91+
/// Syncs summary stats for dashboard UI.
92+
func syncSiteSummaryStats(for siteID: Int64,
93+
timeRange: StatsTimeRangeV4,
94+
latestDateToInclude: Date,
95+
onCompletion: ((Result<Void, Error>) -> Void)? = nil) {
96+
let action = StatsActionV4.retrieveSiteSummaryStats(siteID: siteID,
97+
period: timeRange.summaryStatsGranularity,
98+
quantity: 1,
99+
latestDateToInclude: latestDateToInclude,
100+
saveInStorage: true) { result in
101+
if case let .failure(error) = result {
102+
DDLogError("⛔️ Error synchronizing summary stats: \(error)")
103+
}
104+
105+
let voidResult = result.map { _ in () } // Caller expects no entity in the result.
106+
onCompletion?(voidResult)
107+
}
108+
stores.dispatch(action)
109+
}
110+
91111
/// Syncs top performers data for dashboard UI.
92112
func syncTopEarnersStats(for siteID: Int64,
93113
siteTimezone: TimeZone,

WooCommerce/Classes/ViewRelated/Dashboard/Factories/StatsDataTextFormatter.swift

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -100,22 +100,24 @@ struct StatsDataTextFormatter {
100100

101101
// MARK: Views and Visitors Stats
102102

103-
/// Creates the text to display for the visitor count.
103+
/// Creates the text to display for the visitor count based on SiteVisitStats data and a given interval.
104104
///
105-
static func createVisitorCountText(siteStats: SiteVisitStats?, selectedIntervalIndex: Int?) -> String {
105+
static func createVisitorCountText(siteStats: SiteVisitStats?, selectedIntervalIndex: Int) -> String {
106106
if let visitorCount = visitorCount(at: selectedIntervalIndex, siteStats: siteStats) {
107107
return Double(visitorCount).humanReadableString()
108108
} else {
109109
return Constants.placeholderText
110110
}
111111
}
112112

113-
/// Creates the text to display for the visitor count delta.
113+
/// Creates the text to display for the visitor count based on SiteSummaryStats data.
114114
///
115-
static func createVisitorCountDelta(from previousPeriod: SiteVisitStats?, to currentPeriod: SiteVisitStats?) -> DeltaPercentage {
116-
let previousCount = visitorCount(at: nil, siteStats: previousPeriod)
117-
let currentCount = visitorCount(at: nil, siteStats: currentPeriod)
118-
return createDeltaPercentage(from: previousCount, to: currentCount)
115+
static func createVisitorCountText(siteStats: SiteSummaryStats?) -> String {
116+
guard let visitorCount = siteStats?.visitors else {
117+
return Constants.placeholderText
118+
}
119+
120+
return Double(visitorCount).humanReadableString()
119121
}
120122

121123
/// Creates the text to display for the views count.
@@ -130,9 +132,9 @@ struct StatsDataTextFormatter {
130132

131133
// MARK: Conversion Stats
132134

133-
/// Creates the text to display for the conversion rate.
135+
/// Creates the text to display for the conversion rate based on SiteVisitStats data and a given interval.
134136
///
135-
static func createConversionRateText(orderStats: OrderStatsV4?, siteStats: SiteVisitStats?, selectedIntervalIndex: Int?) -> String {
137+
static func createConversionRateText(orderStats: OrderStatsV4?, siteStats: SiteVisitStats?, selectedIntervalIndex: Int) -> String {
136138
guard let visitors = visitorCount(at: selectedIntervalIndex, siteStats: siteStats),
137139
let orders = orderCount(at: selectedIntervalIndex, orderStats: orderStats) else {
138140
return Constants.placeholderText
@@ -218,16 +220,14 @@ private extension StatsDataTextFormatter {
218220
return numberFormatter
219221
}()
220222

221-
/// Retrieves the visitor count for the provided order stats and, optionally, a specific interval.
223+
/// Retrieves the visitor count for the provided site stats and a specific interval.
222224
///
223-
static func visitorCount(at selectedIndex: Int?, siteStats: SiteVisitStats?) -> Double? {
225+
static func visitorCount(at selectedIndex: Int, siteStats: SiteVisitStats?) -> Double? {
224226
let siteStatsItems = siteStats?.items?.sorted(by: { (lhs, rhs) -> Bool in
225227
return lhs.period < rhs.period
226228
}) ?? []
227-
if let selectedIndex, selectedIndex < siteStatsItems.count {
229+
if selectedIndex < siteStatsItems.count {
228230
return Double(siteStatsItems[selectedIndex].visitors)
229-
} else if let siteStats {
230-
return Double(siteStats.totalVisitors)
231231
} else {
232232
return nil
233233
}

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,10 @@ final class StoreStatsAndTopPerformersPeriodViewController: UIViewController {
7878
// MARK: Child View Controllers
7979

8080
private lazy var storeStatsPeriodViewController: StoreStatsV4PeriodViewController = {
81-
StoreStatsV4PeriodViewController(siteID: siteID, timeRange: timeRange, usageTracksEventEmitter: usageTracksEventEmitter)
81+
StoreStatsV4PeriodViewController(siteID: siteID,
82+
timeRange: timeRange,
83+
currentDate: currentDate,
84+
usageTracksEventEmitter: usageTracksEventEmitter)
8285
}()
8386

8487
private lazy var inAppFeedbackCardViewController = InAppFeedbackCardViewController()

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,21 @@ private extension StoreStatsAndTopPerformersViewController {
287287
periodStoreStatsGroup.leave()
288288
}
289289

290+
group.enter()
291+
periodGroup.enter()
292+
periodStoreStatsGroup.enter()
293+
self.dashboardViewModel.syncSiteSummaryStats(for: siteID,
294+
timeRange: vc.timeRange,
295+
latestDateToInclude: latestDateToInclude) { result in
296+
if case let .failure(error) = result {
297+
DDLogError("⛔️ Error synchronizing summary stats: \(error)")
298+
periodSyncError = error
299+
}
300+
group.leave()
301+
periodGroup.leave()
302+
periodStoreStatsGroup.leave()
303+
}
304+
290305
group.enter()
291306
periodGroup.enter()
292307
self.dashboardViewModel.syncTopEarnersStats(for: siteID,

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

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,20 +41,31 @@ final class StoreStatsPeriodViewModel {
4141

4242
/// Emits visitor stats text values based on site visit stats and selected time interval.
4343
private(set) lazy var visitorStatsText: AnyPublisher<String, Never> =
44-
Publishers.CombineLatest($siteStats.eraseToAnyPublisher(), $selectedIntervalIndex.eraseToAnyPublisher())
45-
.compactMap { siteStats, selectedIntervalIndex in
46-
StatsDataTextFormatter.createVisitorCountText(siteStats: siteStats, selectedIntervalIndex: selectedIntervalIndex)
44+
Publishers.CombineLatest3($siteStats.eraseToAnyPublisher(), $selectedIntervalIndex.eraseToAnyPublisher(), $summaryStats.eraseToAnyPublisher())
45+
.compactMap { siteStats, selectedIntervalIndex, summaryStats in
46+
if let selectedIntervalIndex {
47+
return StatsDataTextFormatter.createVisitorCountText(siteStats: siteStats, selectedIntervalIndex: selectedIntervalIndex)
48+
} else {
49+
return StatsDataTextFormatter.createVisitorCountText(siteStats: summaryStats)
50+
}
4751
}
4852
.removeDuplicates()
4953
.eraseToAnyPublisher()
5054

5155
/// Emits conversion stats text values based on order stats, site visit stats, and selected time interval.
5256
private(set) lazy var conversionStatsText: AnyPublisher<String, Never> =
53-
Publishers.CombineLatest3($orderStatsData.eraseToAnyPublisher(), $siteStats.eraseToAnyPublisher(), $selectedIntervalIndex.eraseToAnyPublisher())
54-
.compactMap { orderStatsData, siteStats, selectedIntervalIndex in
55-
StatsDataTextFormatter.createConversionRateText(orderStats: orderStatsData.stats,
56-
siteStats: siteStats,
57-
selectedIntervalIndex: selectedIntervalIndex)
57+
Publishers.CombineLatest4($orderStatsData.eraseToAnyPublisher(),
58+
$siteStats.eraseToAnyPublisher(),
59+
$selectedIntervalIndex.eraseToAnyPublisher(),
60+
$summaryStats.eraseToAnyPublisher())
61+
.compactMap { orderStatsData, siteStats, selectedIntervalIndex, summaryStats in
62+
if let selectedIntervalIndex {
63+
return StatsDataTextFormatter.createConversionRateText(orderStats: orderStatsData.stats,
64+
siteStats: siteStats,
65+
selectedIntervalIndex: selectedIntervalIndex)
66+
} else {
67+
return StatsDataTextFormatter.createConversionRateText(orderStats: orderStatsData.stats, siteStats: summaryStats)
68+
}
5869
}
5970
.removeDuplicates()
6071
.eraseToAnyPublisher()
@@ -106,6 +117,7 @@ final class StoreStatsPeriodViewModel {
106117
// MARK: - Private data
107118

108119
@Published private var siteStats: SiteVisitStats?
120+
@Published private var summaryStats: SiteSummaryStats?
109121

110122
typealias OrderStatsData = (stats: OrderStatsV4?, intervals: [OrderStatsV4Interval])
111123
@Published private var orderStatsData: OrderStatsData = (nil, [])
@@ -128,13 +140,27 @@ final class StoreStatsPeriodViewModel {
128140
return ResultsController(storageManager: storageManager, matching: predicate, sortedBy: [])
129141
}()
130142

143+
/// SiteSummaryStats ResultsController: Loads site summary stats from the Storage Layer
144+
private lazy var summaryStatsResultsController: ResultsController<StorageSiteSummaryStats> = {
145+
let formattedDateString: String = {
146+
let date = timeRange.latestDate(currentDate: currentDate, siteTimezone: siteTimezone)
147+
return StatsStoreV4.buildDateString(from: date, with: .day)
148+
}()
149+
let predicate = NSPredicate(format: "siteID = %ld AND period == %@ AND date == %@",
150+
siteID,
151+
timeRange.summaryStatsGranularity.rawValue,
152+
formattedDateString)
153+
return ResultsController(storageManager: storageManager, matching: predicate, sortedBy: [])
154+
}()
155+
131156
// MARK: - Configurations
132157

133158
/// Updated externally when reloading data.
134159
var siteTimezone: TimeZone
135160

136161
private let siteID: Int64
137162
private let timeRange: StatsTimeRangeV4
163+
private let currentDate: Date
138164
private let currencyFormatter: CurrencyFormatter
139165
private let storageManager: StorageManagerType
140166
private let currencySettings: CurrencySettings
@@ -144,12 +170,14 @@ final class StoreStatsPeriodViewModel {
144170
init(siteID: Int64,
145171
timeRange: StatsTimeRangeV4,
146172
siteTimezone: TimeZone,
173+
currentDate: Date,
147174
currencyFormatter: CurrencyFormatter,
148175
currencySettings: CurrencySettings,
149176
storageManager: StorageManagerType = ServiceLocator.storageManager) {
150177
self.siteID = siteID
151178
self.timeRange = timeRange
152179
self.siteTimezone = siteTimezone
180+
self.currentDate = currentDate
153181
self.currencyFormatter = currencyFormatter
154182
self.currencySettings = currencySettings
155183
self.storageManager = storageManager
@@ -239,6 +267,7 @@ private extension StoreStatsPeriodViewModel {
239267
func configureResultsControllers() {
240268
configureSiteStatsResultsController()
241269
configureOrderStatsResultsController()
270+
configureSummaryStatsResultsController()
242271
}
243272

244273
func configureOrderStatsResultsController() {
@@ -260,6 +289,16 @@ private extension StoreStatsPeriodViewModel {
260289
}
261290
try? siteStatsResultsController.performFetch()
262291
}
292+
293+
func configureSummaryStatsResultsController() {
294+
summaryStatsResultsController.onDidChangeContent = { [weak self] in
295+
self?.updateSiteSummaryDataIfNeeded()
296+
}
297+
summaryStatsResultsController.onDidResetContent = { [weak self] in
298+
self?.updateSiteSummaryDataIfNeeded()
299+
}
300+
try? summaryStatsResultsController.performFetch()
301+
}
263302
}
264303

265304
// MARK: - Private Helpers
@@ -269,6 +308,10 @@ private extension StoreStatsPeriodViewModel {
269308
siteStats = siteStatsResultsController.fetchedObjects.first
270309
}
271310

311+
func updateSiteSummaryDataIfNeeded() {
312+
summaryStats = summaryStatsResultsController.fetchedObjects.first
313+
}
314+
272315
func updateOrderDataIfNeeded() {
273316
let orderStats = orderStatsResultsController.fetchedObjects.first
274317
let intervals = StatsIntervalDataParser.sortOrderStatsIntervals(from: orderStats)

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ final class StoreStatsV4PeriodViewController: UIViewController {
128128
///
129129
init(siteID: Int64,
130130
timeRange: StatsTimeRangeV4,
131+
currentDate: Date,
131132
currencyFormatter: CurrencyFormatter = CurrencyFormatter(currencySettings: ServiceLocator.currencySettings),
132133
currencySettings: CurrencySettings = ServiceLocator.currencySettings,
133134
usageTracksEventEmitter: StoreStatsUsageTracksEventEmitter) {
@@ -136,6 +137,7 @@ final class StoreStatsV4PeriodViewController: UIViewController {
136137
self.viewModel = StoreStatsPeriodViewModel(siteID: siteID,
137138
timeRange: timeRange,
138139
siteTimezone: siteTimezone,
140+
currentDate: currentDate,
139141
currencyFormatter: currencyFormatter,
140142
currencySettings: currencySettings)
141143
self.usageTracksEventEmitter = usageTracksEventEmitter

WooCommerce/Classes/ViewRelated/MainTabBarController.swift

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -337,7 +337,7 @@ extension MainTabBarController {
337337
}
338338
let siteID = Int64(note.meta.identifier(forKey: .site) ?? Int.min)
339339

340-
switchToStore(with: siteID, onCompletion: { _ in
340+
showStore(with: siteID, onCompletion: { _ in
341341
presentNotificationDetails(for: note)
342342
})
343343
}
@@ -367,8 +367,16 @@ extension MainTabBarController {
367367
"already_read": note.read ])
368368
}
369369

370-
private static func switchToStore(with siteID: Int64, onCompletion: @escaping (Bool) -> Void) {
371-
SwitchStoreUseCase(stores: ServiceLocator.stores).switchToStoreIfSiteIsStored(with: siteID) { siteChanged in
370+
private static func showStore(with siteID: Int64, onCompletion: @escaping (Bool) -> Void) {
371+
let stores = ServiceLocator.stores
372+
373+
// Already showing that store, do nothing
374+
guard siteID != stores.sessionManager.defaultStoreID else {
375+
onCompletion(true)
376+
return
377+
}
378+
379+
SwitchStoreUseCase(stores: stores).switchToStoreIfSiteIsStored(with: siteID) { siteChanged in
372380
guard siteChanged else {
373381
return onCompletion(false)
374382
}
@@ -393,9 +401,10 @@ extension MainTabBarController {
393401
}
394402

395403
static func navigateToOrderDetails(with orderID: Int64, siteID: Int64) {
396-
switchToStore(with: siteID, onCompletion: { siteChanged in
404+
showStore(with: siteID, onCompletion: { storeIsShown in
397405
switchToOrdersTab {
398-
guard siteChanged else {
406+
// It failed to show the order's store. We navigate to the orders tab and stop, as we cannot show the order details screen
407+
guard storeIsShown else {
399408
return
400409
}
401410
// We give some time to the orders tab transition to finish, otherwise it might prevent the second navigation from happening

WooCommerce/WooCommerceTests/ViewRelated/Dashboard/DashboardViewModelTests.swift

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,17 @@ final class DashboardViewModelTests: XCTestCase {
4848
func test_statsVersion_remains_v4_when_non_store_stats_sync_returns_noRestRoute_error() {
4949
// Given
5050
stores.whenReceivingAction(ofType: StatsActionV4.self) { action in
51-
if case let .retrieveStats(_, _, _, _, _, _, completion) = action {
51+
switch action {
52+
case let .retrieveStats(_, _, _, _, _, _, completion):
5253
completion(.failure(DotcomError.empty))
53-
} else if case let .retrieveSiteVisitStats(_, _, _, _, completion) = action {
54+
case let .retrieveSiteVisitStats(_, _, _, _, completion):
55+
completion(.failure(DotcomError.noRestRoute))
56+
case let .retrieveTopEarnerStats(_, _, _, _, _, _, _, completion):
5457
completion(.failure(DotcomError.noRestRoute))
55-
} else if case let .retrieveTopEarnerStats(_, _, _, _, _, _, _, completion) = action {
58+
case let .retrieveSiteSummaryStats(_, _, _, _, _, completion):
5659
completion(.failure(DotcomError.noRestRoute))
60+
default:
61+
XCTFail("Received unsupported action: \(action)")
5762
}
5863
}
5964
let viewModel = DashboardViewModel(stores: stores)
@@ -63,6 +68,7 @@ final class DashboardViewModelTests: XCTestCase {
6368
viewModel.syncStats(for: sampleSiteID, siteTimezone: .current, timeRange: .thisMonth, latestDateToInclude: .init(), forceRefresh: false)
6469
viewModel.syncSiteVisitStats(for: sampleSiteID, siteTimezone: .current, timeRange: .thisMonth, latestDateToInclude: .init())
6570
viewModel.syncTopEarnersStats(for: sampleSiteID, siteTimezone: .current, timeRange: .thisMonth, latestDateToInclude: .init(), forceRefresh: false)
71+
viewModel.syncSiteSummaryStats(for: sampleSiteID, timeRange: .thisMonth, latestDateToInclude: .init())
6672

6773
// Then
6874
XCTAssertEqual(viewModel.statsVersion, .v4)

0 commit comments

Comments
 (0)