diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Factories/StatsDataTextFormatter.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Factories/StatsDataTextFormatter.swift index 0499b6fa7b7..435071315c3 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Factories/StatsDataTextFormatter.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Factories/StatsDataTextFormatter.swift @@ -24,6 +24,16 @@ struct StatsDataTextFormatter { } } + /// Creates the text to display for the total revenue delta. + /// + static func createTotalRevenueDelta(from previousPeriod: OrderStatsV4?, to currentPeriod: OrderStatsV4?) -> String { + if let previousRevenue = totalRevenue(at: nil, orderStats: previousPeriod), let currentRevenue = totalRevenue(at: nil, orderStats: currentPeriod) { + return createDeltaText(from: previousRevenue, to: currentRevenue) + } else { + return Constants.placeholderText + } + } + // MARK: Orders Stats /// Creates the text to display for the order count. @@ -36,6 +46,16 @@ struct StatsDataTextFormatter { } } + /// Creates the text to display for the order count delta. + /// + static func createOrderCountDelta(from previousPeriod: OrderStatsV4?, to currentPeriod: OrderStatsV4?) -> String { + if let previousCount = orderCount(at: nil, orderStats: previousPeriod), let currentCount = orderCount(at: nil, orderStats: currentPeriod) { + return createDeltaText(from: previousCount, to: currentCount) + } else { + return Constants.placeholderText + } + } + /// Creates the text to display for the average order value. /// static func createAverageOrderValueText(orderStats: OrderStatsV4?, currencyFormatter: CurrencyFormatter, currencyCode: String) -> String { @@ -48,6 +68,16 @@ struct StatsDataTextFormatter { } } + /// Creates the text to display for the average order value delta. + /// + static func createAverageOrderValueDelta(from previousPeriod: OrderStatsV4?, to currentPeriod: OrderStatsV4?) -> String { + if let previousAverage = averageOrderValue(orderStats: previousPeriod), let currentAverage = averageOrderValue(orderStats: currentPeriod) { + return createDeltaText(from: previousAverage, to: currentAverage) + } else { + return Constants.placeholderText + } + } + // MARK: Views and Visitors Stats /// Creates the text to display for the visitor count. @@ -60,6 +90,16 @@ struct StatsDataTextFormatter { } } + /// Creates the text to display for the visitor count delta. + /// + static func createVisitorCountDelta(from previousPeriod: SiteVisitStats?, to currentPeriod: SiteVisitStats?) -> String { + if let previousCount = visitorCount(at: nil, siteStats: previousPeriod), let currentCount = visitorCount(at: nil, siteStats: currentPeriod) { + return createDeltaText(from: previousCount, to: currentCount) + } else { + return Constants.placeholderText + } + } + // MARK: Conversion Stats /// Creates the text to display for the conversion rate. @@ -82,6 +122,28 @@ struct StatsDataTextFormatter { return Constants.placeholderText } } +} + +extension StatsDataTextFormatter { + + // MARK: Delta Calculations + + /// Creates the text showing the percent change from the previous `Decimal` value to the current `Decimal` value + /// + static func createDeltaText(from previousValue: Decimal, to currentValue: Decimal) -> String { + guard previousValue > 0 else { + return deltaNumberFormatter.string(from: 1) ?? "+100%" + } + + let deltaValue = ((currentValue - previousValue) / previousValue) + return deltaNumberFormatter.string(from: deltaValue as NSNumber) ?? Constants.placeholderText + } + + /// Creates the text showing the percent change from the previous `Double` value to the current `Double` value + /// + static func createDeltaText(from previousValue: Double, to currentValue: Double) -> String { + createDeltaText(from: Decimal(previousValue), to: Decimal(currentValue)) + } // MARK: Stats Intervals @@ -98,6 +160,16 @@ struct StatsDataTextFormatter { // MARK: - Private helpers private extension StatsDataTextFormatter { + + /// Number formatter for delta percentages, e.g. `+36%` or `-16%`. + /// + static let deltaNumberFormatter: NumberFormatter = { + let numberFormatter = NumberFormatter() + numberFormatter.numberStyle = .percent + numberFormatter.positivePrefix = numberFormatter.plusSign + return numberFormatter + }() + /// Retrieves the visitor count for the provided order stats and, optionally, a specific interval. /// static func visitorCount(at selectedIndex: Int?, siteStats: SiteVisitStats?) -> Double? { diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/StatsDataTextFormatterTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/StatsDataTextFormatterTests.swift index 09307595665..7218bbc7aad 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/StatsDataTextFormatterTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/StatsDataTextFormatterTests.swift @@ -62,6 +62,18 @@ final class StatsDataTextFormatterTests: XCTestCase { XCTAssertEqual(totalRevenue, "$25") } + func test_createTotalRevenueDelta_returns_expected_delta_text() { + // Given + let previousOrderStats = OrderStatsV4.fake().copy(totals: .fake().copy(grossRevenue: 10)) + let currentOrderStats = OrderStatsV4.fake().copy(totals: .fake().copy(grossRevenue: 15)) + + // When + let totalRevenueDelta = StatsDataTextFormatter.createTotalRevenueDelta(from: previousOrderStats, to: currentOrderStats) + + // Then + XCTAssertEqual(totalRevenueDelta, "+50%") + } + // MARK: Orders Stats func test_createOrderCountText_returns_expected_order_count() { @@ -94,6 +106,18 @@ final class StatsDataTextFormatterTests: XCTestCase { XCTAssertEqual(orderCount, "1") } + func test_createOrderCountDelta_returns_expected_delta_text() { + // Given + let previousOrderStats = OrderStatsV4.fake().copy(totals: .fake().copy(totalOrders: 10)) + let currentOrderStats = OrderStatsV4.fake().copy(totals: .fake().copy(totalOrders: 15)) + + // When + let orderCountDelta = StatsDataTextFormatter.createOrderCountDelta(from: previousOrderStats, to: currentOrderStats) + + // Then + XCTAssertEqual(orderCountDelta, "+50%") + } + func test_createAverageOrderValueText_does_not_return_decimal_points_for_integer_value() { // Given let orderStats = OrderStatsV4.fake().copy(totals: .fake().copy(averageOrderValue: 62)) @@ -120,6 +144,18 @@ final class StatsDataTextFormatterTests: XCTestCase { XCTAssertEqual(averageOrderValue, "$62.86") } + func test_createAverageOrderValueDelta_returns_expected_delta_text() { + // Given + let previousOrderStats = OrderStatsV4.fake().copy(totals: .fake().copy(averageOrderValue: 10.00)) + let currentOrderStats = OrderStatsV4.fake().copy(totals: .fake().copy(averageOrderValue: 15.00)) + + // When + let averageOrderValueDelta = StatsDataTextFormatter.createAverageOrderValueDelta(from: previousOrderStats, to: currentOrderStats) + + // Then + XCTAssertEqual(averageOrderValueDelta, "+50%") + } + // MARK: Views and Visitors Stats // This test reflects the current method for computing total visitor count. @@ -150,6 +186,18 @@ final class StatsDataTextFormatterTests: XCTestCase { XCTAssertEqual(visitorCount, "17") } + func test_createVisitorCountDelta_returns_expected_delta_text() { + // 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, "+50%") + } + // MARK: Conversion Stats func test_createConversionRateText_returns_placeholder_when_visitor_count_is_zero() { @@ -200,4 +248,54 @@ final class StatsDataTextFormatterTests: XCTestCase { // Then XCTAssertEqual(conversionRate, "10%") } + + // MARK: Delta Calculations + + func test_createDeltaText_returns_expected_positive_text() { + // Given + let previousValue: Double = 100 + let currentValue: Double = 150 + + // When + let deltaText = StatsDataTextFormatter.createDeltaText(from: previousValue, to: currentValue) + + // Then + XCTAssertEqual(deltaText, "+50%") + } + + func test_createDeltaText_returns_expected_negative_text() { + // Given + let previousValue: Double = 150 + let currentValue: Double = 100 + + // When + let deltaText = StatsDataTextFormatter.createDeltaText(from: previousValue, to: currentValue) + + // Then + XCTAssertEqual(deltaText, "-33%") + } + + func test_createDeltaText_returns_100_percent_change_when_previous_value_is_zero() { + // Given + let previousValue: Double = 0 + let currentValue: Double = 10 + + // When + let deltaText = StatsDataTextFormatter.createDeltaText(from: previousValue, to: currentValue) + + // Then + XCTAssertEqual(deltaText, "+100%") + } + + func test_createDeltaText_returns_negative_100_percent_change_when_current_value_is_zero() { + // Given + let previousValue: Double = 10 + let currentValue: Double = 0 + + // When + let deltaText = StatsDataTextFormatter.createDeltaText(from: previousValue, to: currentValue) + + // Then + XCTAssertEqual(deltaText, "-100%") + } }