Skip to content

Commit d71c1cf

Browse files
authored
Merge pull request #8427 from woocommerce/issue/8173-sitesummarystats-dashboard-display
[My Store] Use stored site summary stats for visitors and conversion rate
2 parents bfe79cc + 8ad0ff4 commit d71c1cf

File tree

8 files changed

+150
-70
lines changed

8 files changed

+150
-70
lines changed

RELEASE-NOTES.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
11.7
44
-----
55
- [**] Analytics Hub: Now you can select custom date ranges. [https://github.com/woocommerce/woocommerce-ios/pull/8414]
6+
- [*] 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]
67

78

89
11.6

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/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/WooCommerceTests/ViewRelated/Dashboard/Factories/StatsDataTextFormatterTests.swift

Lines changed: 8 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -292,21 +292,18 @@ final class StatsDataTextFormatterTests: XCTestCase {
292292

293293
// MARK: Views and Visitors Stats
294294

295-
// This test reflects the current method for computing total visitor count.
296-
// It needs to be updated once this issue is fixed: https://github.com/woocommerce/woocommerce-ios/issues/8173
297-
func test_createVisitorCountText_returns_expected_visitor_stats() {
295+
func test_createVisitorCountText_for_SiteSummaryStats_returns_expected_visitor_stats() {
298296
// Given
299-
let siteVisitStats = Yosemite.SiteVisitStats.fake().copy(items: [.fake().copy(period: "1", visitors: 17),
300-
.fake().copy(period: "0", visitors: 5)])
297+
let siteSummaryStats = Yosemite.SiteSummaryStats.fake().copy(visitors: 20)
301298

302299
// When
303-
let visitorCount = StatsDataTextFormatter.createVisitorCountText(siteStats: siteVisitStats, selectedIntervalIndex: nil)
300+
let visitorCount = StatsDataTextFormatter.createVisitorCountText(siteStats: siteSummaryStats)
304301

305302
// Then
306-
XCTAssertEqual(visitorCount, "22")
303+
XCTAssertEqual(visitorCount, "20")
307304
}
308305

309-
func test_createVisitorCountText_returns_expected_text_for_selected_interval() {
306+
func test_createVisitorCountText_for_SiteVisitStats_returns_expected_text_for_selected_interval() {
310307
// Given
311308
let siteVisitStats = Yosemite.SiteVisitStats.fake().copy(items: [.fake().copy(period: "1", visitors: 17),
312309
.fake().copy(period: "0", visitors: 5)])
@@ -320,19 +317,6 @@ final class StatsDataTextFormatterTests: XCTestCase {
320317
XCTAssertEqual(visitorCount, "17")
321318
}
322319

323-
func test_createVisitorCountDelta_returns_expected_delta() {
324-
// Given
325-
let previousSiteStats = SiteVisitStats.fake().copy(items: [.fake().copy(period: "0", visitors: 10)])
326-
let currentSiteStats = SiteVisitStats.fake().copy(items: [.fake().copy(period: "0", visitors: 15)])
327-
328-
// When
329-
let visitorCountDelta = StatsDataTextFormatter.createVisitorCountDelta(from: previousSiteStats, to: currentSiteStats)
330-
331-
// Then
332-
XCTAssertEqual(visitorCountDelta.string, "+50%")
333-
XCTAssertEqual(visitorCountDelta.direction, .positive)
334-
}
335-
336320
func test_createViewsCountText_returns_expected_views_stats() {
337321
// Given
338322
let siteVisitStats = SiteSummaryStats.fake().copy(views: 250)
@@ -352,7 +336,7 @@ final class StatsDataTextFormatterTests: XCTestCase {
352336
let orderStats = OrderStatsV4.fake().copy(totals: .fake().copy(totalOrders: 3))
353337

354338
// When
355-
let conversionRate = StatsDataTextFormatter.createConversionRateText(orderStats: orderStats, siteStats: siteVisitStats, selectedIntervalIndex: nil)
339+
let conversionRate = StatsDataTextFormatter.createConversionRateText(orderStats: orderStats, siteStats: siteVisitStats, selectedIntervalIndex: 0)
356340

357341
// Then
358342
XCTAssertEqual(conversionRate, "0%")
@@ -364,7 +348,7 @@ final class StatsDataTextFormatterTests: XCTestCase {
364348
let orderStats = OrderStatsV4.fake().copy(totals: .fake().copy(totalOrders: 3557))
365349

366350
// When
367-
let conversionRate = StatsDataTextFormatter.createConversionRateText(orderStats: orderStats, siteStats: siteVisitStats, selectedIntervalIndex: nil)
351+
let conversionRate = StatsDataTextFormatter.createConversionRateText(orderStats: orderStats, siteStats: siteVisitStats, selectedIntervalIndex: 0)
368352

369353
// Then
370354
XCTAssertEqual(conversionRate, "35.6%") // order count: 3557, visitor count: 10000 => 0.3557 (35.57%)
@@ -375,24 +359,11 @@ final class StatsDataTextFormatterTests: XCTestCase {
375359
let siteVisitStats = Yosemite.SiteVisitStats.fake().copy(items: [.fake().copy(visitors: 10)])
376360
let orderStats = OrderStatsV4.fake().copy(totals: .fake().copy(totalOrders: 3))
377361

378-
// When
379-
let conversionRate = StatsDataTextFormatter.createConversionRateText(orderStats: orderStats, siteStats: siteVisitStats, selectedIntervalIndex: nil)
380-
381-
// Then
382-
XCTAssertEqual(conversionRate, "30%") // order count: 3, visitor count: 10 => 0.3 (30%)
383-
}
384-
385-
func test_createConversionRateText_for_SiteVisitStats_returns_expected_text_for_selected_interval() {
386-
// Given
387-
let siteVisitStats = Yosemite.SiteVisitStats.fake().copy(items: [.fake().copy(visitors: 10)])
388-
let orderStats = OrderStatsV4.fake().copy(totals: .fake().copy(totalOrders: 2),
389-
intervals: [.fake().copy(subtotals: .fake().copy(totalOrders: 1))])
390-
391362
// When
392363
let conversionRate = StatsDataTextFormatter.createConversionRateText(orderStats: orderStats, siteStats: siteVisitStats, selectedIntervalIndex: 0)
393364

394365
// Then
395-
XCTAssertEqual(conversionRate, "10%")
366+
XCTAssertEqual(conversionRate, "30%") // order count: 3, visitor count: 10 => 0.3 (30%)
396367
}
397368

398369
func test_createConversionRateText_for_SiteSummaryStats_returns_placeholder_when_visitor_count_is_zero() {

0 commit comments

Comments
 (0)