diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Factories/StatsDataTextFormatter.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Factories/StatsDataTextFormatter.swift index 435071315c3..cebb9273718 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Factories/StatsDataTextFormatter.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Factories/StatsDataTextFormatter.swift @@ -26,12 +26,10 @@ 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 - } + static func createTotalRevenueDelta(from previousPeriod: OrderStatsV4?, to currentPeriod: OrderStatsV4?) -> DeltaPercentage { + let previousRevenue = totalRevenue(at: nil, orderStats: previousPeriod) + let currentRevenue = totalRevenue(at: nil, orderStats: currentPeriod) + return createDeltaPercentage(from: previousRevenue, to: currentRevenue) } // MARK: Orders Stats @@ -48,12 +46,10 @@ 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 - } + static func createOrderCountDelta(from previousPeriod: OrderStatsV4?, to currentPeriod: OrderStatsV4?) -> DeltaPercentage { + let previousCount = orderCount(at: nil, orderStats: previousPeriod) + let currentCount = orderCount(at: nil, orderStats: currentPeriod) + return createDeltaPercentage(from: previousCount, to: currentCount) } /// Creates the text to display for the average order value. @@ -70,12 +66,10 @@ 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 - } + static func createAverageOrderValueDelta(from previousPeriod: OrderStatsV4?, to currentPeriod: OrderStatsV4?) -> DeltaPercentage { + let previousAverage = averageOrderValue(orderStats: previousPeriod) + let currentAverage = averageOrderValue(orderStats: currentPeriod) + return createDeltaPercentage(from: previousAverage, to: currentAverage) } // MARK: Views and Visitors Stats @@ -92,12 +86,10 @@ 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 - } + 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) } // MARK: Conversion Stats @@ -128,21 +120,59 @@ extension StatsDataTextFormatter { // MARK: Delta Calculations - /// Creates the text showing the percent change from the previous `Decimal` value to the current `Decimal` value + /// Creates the `DeltaPercentage` for 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%" + static func createDeltaPercentage(from previousValue: Decimal?, to currentValue: Decimal?) -> DeltaPercentage { + guard let previousValue, let currentValue, previousValue != currentValue else { + return DeltaPercentage(value: 0) // Missing or equal values: 0% change } - let deltaValue = ((currentValue - previousValue) / previousValue) - return deltaNumberFormatter.string(from: deltaValue as NSNumber) ?? Constants.placeholderText + // If the previous value was 0, return a 100% or -100% change + guard previousValue != 0 else { + let deltaValue: Decimal = currentValue > 0 ? 1 : -1 + return DeltaPercentage(value: deltaValue) + } + + return DeltaPercentage(value: (currentValue - previousValue) / previousValue) } - /// Creates the text showing the percent change from the previous `Double` value to the current `Double` value + /// Creates the `DeltaPercentage` for 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)) + static func createDeltaPercentage(from previousValue: Double?, to currentValue: Double?) -> DeltaPercentage { + guard let previousValue, let currentValue else { + return DeltaPercentage(value: 0) // Missing data: 0% change + } + + return createDeltaPercentage(from: Decimal(previousValue), to: Decimal(currentValue)) + } + + /// Represents a formatted delta percentage string and its direction of change + struct DeltaPercentage { + /// The delta percentage formatted as a localized string (e.g. `+100%`) + let string: String + + /// The direction of change + let direction: Direction + + init(value: Decimal) { + self.string = deltaNumberFormatter.string(from: value as NSNumber) ?? Constants.placeholderText + self.direction = { + if value > 0 { + return .positive + } else if value < 0 { + return .negative + } else { + return .zero + } + }() + } + + /// Represents the direction of change for a delta value + enum Direction { + case positive + case negative + case zero + } } // MARK: Stats Intervals diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/StatsDataTextFormatterTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/StatsDataTextFormatterTests.swift index 7218bbc7aad..44d6a00f9e3 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/StatsDataTextFormatterTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Dashboard/StatsDataTextFormatterTests.swift @@ -62,7 +62,7 @@ final class StatsDataTextFormatterTests: XCTestCase { XCTAssertEqual(totalRevenue, "$25") } - func test_createTotalRevenueDelta_returns_expected_delta_text() { + func test_createTotalRevenueDelta_returns_expected_delta() { // Given let previousOrderStats = OrderStatsV4.fake().copy(totals: .fake().copy(grossRevenue: 10)) let currentOrderStats = OrderStatsV4.fake().copy(totals: .fake().copy(grossRevenue: 15)) @@ -71,7 +71,8 @@ final class StatsDataTextFormatterTests: XCTestCase { let totalRevenueDelta = StatsDataTextFormatter.createTotalRevenueDelta(from: previousOrderStats, to: currentOrderStats) // Then - XCTAssertEqual(totalRevenueDelta, "+50%") + XCTAssertEqual(totalRevenueDelta.string, "+50%") + XCTAssertEqual(totalRevenueDelta.direction, .positive) } // MARK: Orders Stats @@ -106,7 +107,7 @@ final class StatsDataTextFormatterTests: XCTestCase { XCTAssertEqual(orderCount, "1") } - func test_createOrderCountDelta_returns_expected_delta_text() { + func test_createOrderCountDelta_returns_expected_delta() { // Given let previousOrderStats = OrderStatsV4.fake().copy(totals: .fake().copy(totalOrders: 10)) let currentOrderStats = OrderStatsV4.fake().copy(totals: .fake().copy(totalOrders: 15)) @@ -115,7 +116,8 @@ final class StatsDataTextFormatterTests: XCTestCase { let orderCountDelta = StatsDataTextFormatter.createOrderCountDelta(from: previousOrderStats, to: currentOrderStats) // Then - XCTAssertEqual(orderCountDelta, "+50%") + XCTAssertEqual(orderCountDelta.string, "+50%") + XCTAssertEqual(orderCountDelta.direction, .positive) } func test_createAverageOrderValueText_does_not_return_decimal_points_for_integer_value() { @@ -144,7 +146,7 @@ final class StatsDataTextFormatterTests: XCTestCase { XCTAssertEqual(averageOrderValue, "$62.86") } - func test_createAverageOrderValueDelta_returns_expected_delta_text() { + func test_createAverageOrderValueDelta_returns_expected_delta() { // Given let previousOrderStats = OrderStatsV4.fake().copy(totals: .fake().copy(averageOrderValue: 10.00)) let currentOrderStats = OrderStatsV4.fake().copy(totals: .fake().copy(averageOrderValue: 15.00)) @@ -153,7 +155,8 @@ final class StatsDataTextFormatterTests: XCTestCase { let averageOrderValueDelta = StatsDataTextFormatter.createAverageOrderValueDelta(from: previousOrderStats, to: currentOrderStats) // Then - XCTAssertEqual(averageOrderValueDelta, "+50%") + XCTAssertEqual(averageOrderValueDelta.string, "+50%") + XCTAssertEqual(averageOrderValueDelta.direction, .positive) } // MARK: Views and Visitors Stats @@ -186,7 +189,7 @@ final class StatsDataTextFormatterTests: XCTestCase { XCTAssertEqual(visitorCount, "17") } - func test_createVisitorCountDelta_returns_expected_delta_text() { + 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)]) @@ -195,7 +198,8 @@ final class StatsDataTextFormatterTests: XCTestCase { let visitorCountDelta = StatsDataTextFormatter.createVisitorCountDelta(from: previousSiteStats, to: currentSiteStats) // Then - XCTAssertEqual(visitorCountDelta, "+50%") + XCTAssertEqual(visitorCountDelta.string, "+50%") + XCTAssertEqual(visitorCountDelta.direction, .positive) } // MARK: Conversion Stats @@ -251,51 +255,94 @@ final class StatsDataTextFormatterTests: XCTestCase { // MARK: Delta Calculations - func test_createDeltaText_returns_expected_positive_text() { + func test_createDeltaPercentage_returns_expected_positive_delta() { // Given let previousValue: Double = 100 let currentValue: Double = 150 // When - let deltaText = StatsDataTextFormatter.createDeltaText(from: previousValue, to: currentValue) + let delta = StatsDataTextFormatter.createDeltaPercentage(from: previousValue, to: currentValue) // Then - XCTAssertEqual(deltaText, "+50%") + XCTAssertEqual(delta.string, "+50%") + XCTAssertEqual(delta.direction, .positive) } - func test_createDeltaText_returns_expected_negative_text() { + func test_createDeltaPercentage_returns_expected_negative_delta() { // Given - let previousValue: Double = 150 + let previousValue: Double = 100 + let currentValue: Double = 50 + + // When + let delta = StatsDataTextFormatter.createDeltaPercentage(from: previousValue, to: currentValue) + + // Then + XCTAssertEqual(delta.string, "-50%") + XCTAssertEqual(delta.direction, .negative) + } + + func test_createDeltaPercentage_returns_expected_zero_delta() { + // Given + let previousValue: Double = 100 let currentValue: Double = 100 // When - let deltaText = StatsDataTextFormatter.createDeltaText(from: previousValue, to: currentValue) + let delta = StatsDataTextFormatter.createDeltaPercentage(from: previousValue, to: currentValue) + + // Then + XCTAssertEqual(delta.string, "+0%") + XCTAssertEqual(delta.direction, .zero) + } + + func test_createDeltaPercentage_returns_expected_zero_delta_for_zero_values() { + // Given + let previousValue: Double = 0 + let currentValue: Double = 0 + + // When + let delta = StatsDataTextFormatter.createDeltaPercentage(from: previousValue, to: currentValue) // Then - XCTAssertEqual(deltaText, "-33%") + XCTAssertEqual(delta.string, "+0%") + XCTAssertEqual(delta.direction, .zero) } - func test_createDeltaText_returns_100_percent_change_when_previous_value_is_zero() { + func test_createDeltaPercentage_returns_positive_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) + let delta = StatsDataTextFormatter.createDeltaPercentage(from: previousValue, to: currentValue) + + // Then + XCTAssertEqual(delta.string, "+100%") + XCTAssertEqual(delta.direction, .positive) + } + + func test_createDeltaPercentage_returns_negative_100_percent_change_when_previous_value_is_zero() { + // Given + let previousValue: Double = 0 + let currentValue: Double = -10 + + // When + let delta = StatsDataTextFormatter.createDeltaPercentage(from: previousValue, to: currentValue) // Then - XCTAssertEqual(deltaText, "+100%") + XCTAssertEqual(delta.string, "-100%") + XCTAssertEqual(delta.direction, .negative) } - func test_createDeltaText_returns_negative_100_percent_change_when_current_value_is_zero() { + func test_createDeltaPercentage_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) + let delta = StatsDataTextFormatter.createDeltaPercentage(from: previousValue, to: currentValue) // Then - XCTAssertEqual(deltaText, "-100%") + XCTAssertEqual(delta.string, "-100%") + XCTAssertEqual(delta.direction, .negative) } }