Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
1 change: 1 addition & 0 deletions RELEASE-NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
11.7
-----
- [**] Analytics Hub: Now you can select custom date ranges. [https://github.com/woocommerce/woocommerce-ios/pull/8414]
- [*] 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]


11.6
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ struct StatsDataTextFormatter {

// MARK: Views and Visitors Stats

/// Creates the text to display for the visitor count.
/// Creates the text to display for the visitor count based on SiteVisitStats data and a given interval.
///
static func createVisitorCountText(siteStats: SiteVisitStats?, selectedIntervalIndex: Int?) -> String {
if let visitorCount = visitorCount(at: selectedIntervalIndex, siteStats: siteStats) {
Expand All @@ -110,12 +110,14 @@ struct StatsDataTextFormatter {
}
}

/// Creates the text to display for the visitor count delta.
/// Creates the text to display for the visitor count based on SiteSummaryStats data.
///
static func createVisitorCountDelta(from previousPeriod: SiteVisitStats?, to currentPeriod: SiteVisitStats?) -> DeltaPercentage {
let previousCount = visitorCount(at: nil, siteStats: previousPeriod)
let currentCount = visitorCount(at: nil, siteStats: currentPeriod)
return createDeltaPercentage(from: previousCount, to: currentCount)
static func createVisitorCountText(siteStats: SiteSummaryStats?) -> String {
guard let visitorCount = siteStats?.visitors else {
return Constants.placeholderText
}

return Double(visitorCount).humanReadableString()
}

/// Creates the text to display for the views count.
Expand All @@ -130,7 +132,7 @@ struct StatsDataTextFormatter {

// MARK: Conversion Stats

/// Creates the text to display for the conversion rate.
/// Creates the text to display for the conversion rate based on SiteVisitStats data and a given interval.
///
static func createConversionRateText(orderStats: OrderStatsV4?, siteStats: SiteVisitStats?, selectedIntervalIndex: Int?) -> String {
guard let visitors = visitorCount(at: selectedIntervalIndex, siteStats: siteStats),
Expand Down Expand Up @@ -218,16 +220,14 @@ private extension StatsDataTextFormatter {
return numberFormatter
}()

/// Retrieves the visitor count for the provided order stats and, optionally, a specific interval.
/// Retrieves the visitor count for the provided site stats and 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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,10 @@ final class StoreStatsAndTopPerformersPeriodViewController: UIViewController {
// MARK: Child View Controllers

private lazy var storeStatsPeriodViewController: StoreStatsV4PeriodViewController = {
StoreStatsV4PeriodViewController(siteID: siteID, timeRange: timeRange, usageTracksEventEmitter: usageTracksEventEmitter)
StoreStatsV4PeriodViewController(siteID: siteID,
timeRange: timeRange,
currentDate: currentDate,
usageTracksEventEmitter: usageTracksEventEmitter)
}()

private lazy var inAppFeedbackCardViewController = InAppFeedbackCardViewController()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,20 +41,31 @@ final class StoreStatsPeriodViewModel {

/// 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 { siteStats, selectedIntervalIndex in
StatsDataTextFormatter.createVisitorCountText(siteStats: siteStats, selectedIntervalIndex: selectedIntervalIndex)
Publishers.CombineLatest3($siteStats.eraseToAnyPublisher(), $selectedIntervalIndex.eraseToAnyPublisher(), $summaryStats.eraseToAnyPublisher())
.compactMap { siteStats, selectedIntervalIndex, summaryStats in
if let selectedIntervalIndex {
return StatsDataTextFormatter.createVisitorCountText(siteStats: siteStats, selectedIntervalIndex: selectedIntervalIndex)
} else {
return StatsDataTextFormatter.createVisitorCountText(siteStats: summaryStats)
}
}
.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 { orderStatsData, siteStats, selectedIntervalIndex in
StatsDataTextFormatter.createConversionRateText(orderStats: orderStatsData.stats,
siteStats: siteStats,
selectedIntervalIndex: selectedIntervalIndex)
Publishers.CombineLatest4($orderStatsData.eraseToAnyPublisher(),
$siteStats.eraseToAnyPublisher(),
$selectedIntervalIndex.eraseToAnyPublisher(),
$summaryStats.eraseToAnyPublisher())
.compactMap { orderStatsData, siteStats, selectedIntervalIndex, summaryStats in
if let selectedIntervalIndex {
return StatsDataTextFormatter.createConversionRateText(orderStats: orderStatsData.stats,
siteStats: siteStats,
selectedIntervalIndex: selectedIntervalIndex)
} else {
return StatsDataTextFormatter.createConversionRateText(orderStats: orderStatsData.stats, siteStats: summaryStats)
}
}
.removeDuplicates()
.eraseToAnyPublisher()
Expand Down Expand Up @@ -106,6 +117,7 @@ final class StoreStatsPeriodViewModel {
// MARK: - Private data

@Published private var siteStats: SiteVisitStats?
@Published private var summaryStats: SiteSummaryStats?

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

/// SiteSummaryStats ResultsController: Loads site summary stats from the Storage Layer
private lazy var summaryStatsResultsController: ResultsController<StorageSiteSummaryStats> = {
let formattedDateString: String = {
let date = timeRange.latestDate(currentDate: currentDate, siteTimezone: siteTimezone)
return StatsStoreV4.buildDateString(from: date, with: .day)
}()
let predicate = NSPredicate(format: "siteID = %ld AND period == %@ AND date == %@",
siteID,
timeRange.summaryStatsGranularity.rawValue,
formattedDateString)
return ResultsController(storageManager: storageManager, matching: predicate, sortedBy: [])
}()

// MARK: - Configurations

/// Updated externally when reloading data.
var siteTimezone: TimeZone

private let siteID: Int64
private let timeRange: StatsTimeRangeV4
private let currentDate: Date
private let currencyFormatter: CurrencyFormatter
private let storageManager: StorageManagerType
private let currencySettings: CurrencySettings
Expand All @@ -144,12 +170,14 @@ final class StoreStatsPeriodViewModel {
init(siteID: Int64,
timeRange: StatsTimeRangeV4,
siteTimezone: TimeZone,
currentDate: Date,
currencyFormatter: CurrencyFormatter,
currencySettings: CurrencySettings,
storageManager: StorageManagerType = ServiceLocator.storageManager) {
self.siteID = siteID
self.timeRange = timeRange
self.siteTimezone = siteTimezone
self.currentDate = currentDate
self.currencyFormatter = currencyFormatter
self.currencySettings = currencySettings
self.storageManager = storageManager
Expand Down Expand Up @@ -239,6 +267,7 @@ private extension StoreStatsPeriodViewModel {
func configureResultsControllers() {
configureSiteStatsResultsController()
configureOrderStatsResultsController()
configureSummaryStatsResultsController()
}

func configureOrderStatsResultsController() {
Expand All @@ -260,6 +289,16 @@ private extension StoreStatsPeriodViewModel {
}
try? siteStatsResultsController.performFetch()
}

func configureSummaryStatsResultsController() {
summaryStatsResultsController.onDidChangeContent = { [weak self] in
self?.updateSiteSummaryDataIfNeeded()
}
summaryStatsResultsController.onDidResetContent = { [weak self] in
self?.updateSiteSummaryDataIfNeeded()
}
try? summaryStatsResultsController.performFetch()
}
}

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

func updateSiteSummaryDataIfNeeded() {
summaryStats = summaryStatsResultsController.fetchedObjects.first
}

func updateOrderDataIfNeeded() {
let orderStats = orderStatsResultsController.fetchedObjects.first
let intervals = StatsIntervalDataParser.sortOrderStatsIntervals(from: orderStats)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ final class StoreStatsV4PeriodViewController: UIViewController {
///
init(siteID: Int64,
timeRange: StatsTimeRangeV4,
currentDate: Date,
currencyFormatter: CurrencyFormatter = CurrencyFormatter(currencySettings: ServiceLocator.currencySettings),
currencySettings: CurrencySettings = ServiceLocator.currencySettings,
usageTracksEventEmitter: StoreStatsUsageTracksEventEmitter) {
Expand All @@ -136,6 +137,7 @@ final class StoreStatsV4PeriodViewController: UIViewController {
self.viewModel = StoreStatsPeriodViewModel(siteID: siteID,
timeRange: timeRange,
siteTimezone: siteTimezone,
currentDate: currentDate,
currencyFormatter: currencyFormatter,
currencySettings: currencySettings)
self.usageTracksEventEmitter = usageTracksEventEmitter
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -292,21 +292,18 @@ final class StatsDataTextFormatterTests: XCTestCase {

// MARK: Views and Visitors Stats

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

// When
let visitorCount = StatsDataTextFormatter.createVisitorCountText(siteStats: siteVisitStats, selectedIntervalIndex: nil)
let visitorCount = StatsDataTextFormatter.createVisitorCountText(siteStats: siteSummaryStats)

// Then
XCTAssertEqual(visitorCount, "22")
XCTAssertEqual(visitorCount, "20")
}

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

func test_createVisitorCountDelta_returns_expected_delta() {
// Given
let previousSiteStats = SiteVisitStats.fake().copy(items: [.fake().copy(period: "0", visitors: 10)])
let currentSiteStats = SiteVisitStats.fake().copy(items: [.fake().copy(period: "0", visitors: 15)])

// When
let visitorCountDelta = StatsDataTextFormatter.createVisitorCountDelta(from: previousSiteStats, to: currentSiteStats)

// Then
XCTAssertEqual(visitorCountDelta.string, "+50%")
XCTAssertEqual(visitorCountDelta.direction, .positive)
}

func test_createViewsCountText_returns_expected_views_stats() {
// Given
let siteVisitStats = SiteSummaryStats.fake().copy(views: 250)
Expand All @@ -352,7 +336,7 @@ final class StatsDataTextFormatterTests: XCTestCase {
let orderStats = OrderStatsV4.fake().copy(totals: .fake().copy(totalOrders: 3))

// When
let conversionRate = StatsDataTextFormatter.createConversionRateText(orderStats: orderStats, siteStats: siteVisitStats, selectedIntervalIndex: nil)
let conversionRate = StatsDataTextFormatter.createConversionRateText(orderStats: orderStats, siteStats: siteVisitStats, selectedIntervalIndex: 0)

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

// When
let conversionRate = StatsDataTextFormatter.createConversionRateText(orderStats: orderStats, siteStats: siteVisitStats, selectedIntervalIndex: nil)
let conversionRate = StatsDataTextFormatter.createConversionRateText(orderStats: orderStats, siteStats: siteVisitStats, selectedIntervalIndex: 0)

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

// When
let conversionRate = StatsDataTextFormatter.createConversionRateText(orderStats: orderStats, siteStats: siteVisitStats, selectedIntervalIndex: nil)

// Then
XCTAssertEqual(conversionRate, "30%") // order count: 3, visitor count: 10 => 0.3 (30%)
}

func test_createConversionRateText_for_SiteVisitStats_returns_expected_text_for_selected_interval() {
// Given
let siteVisitStats = Yosemite.SiteVisitStats.fake().copy(items: [.fake().copy(visitors: 10)])
let orderStats = OrderStatsV4.fake().copy(totals: .fake().copy(totalOrders: 2),
intervals: [.fake().copy(subtotals: .fake().copy(totalOrders: 1))])

// When
let conversionRate = StatsDataTextFormatter.createConversionRateText(orderStats: orderStats, siteStats: siteVisitStats, selectedIntervalIndex: 0)

// Then
XCTAssertEqual(conversionRate, "10%")
XCTAssertEqual(conversionRate, "30%") // order count: 3, visitor count: 10 => 0.3 (30%)
}

func test_createConversionRateText_for_SiteSummaryStats_returns_placeholder_when_visitor_count_is_zero() {
Expand Down
Loading