From 973f8eeb9776f5ef5ca00812d7d8561fd8461b8c Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Wed, 24 Sep 2025 14:31:18 +0800 Subject: [PATCH 1/2] Refactor `WaitingTimeTracker` to remove `Analytics` dependency so that it can be moved to WooFoundation. `AppStartupWaitingTimeTracker` is now a wrapper of `WaitingTimeTracker`. --- .../Utilities/WaitingTimeTracker.swift | 86 ++++++++++ .../Utilities/WaitingTimeTrackerTests.swift | 113 +++++++++++++ .../AppStartupWaitingTimeTracker.swift | 12 +- .../Analytics/WaitingTimeTracker.swift | 52 ------ .../Analytics/WooAnalyticsEvent+WooApp.swift | 39 ----- WooCommerce/Classes/AppDelegate.swift | 2 +- .../PointOfSaleDashboardView.swift | 4 +- .../Analytics Hub/AnalyticsHubViewModel.swift | 5 +- .../Onboarding/StoreOnboardingViewModel.swift | 2 +- .../StorePerformanceViewModel.swift | 6 +- .../OrderDetailsViewController.swift | 2 +- .../WooCommerce.xcodeproj/project.pbxproj | 8 - .../AppStartupWaitingTimeTrackerTests.swift | 2 +- .../System/WaitingTimeTrackerTests.swift | 148 ------------------ .../Privacy/PrivacyBannerViewModelTest.swift | 14 +- .../UpdateAnalyticsSettingsUseCaseTests.swift | 37 ++++- 16 files changed, 260 insertions(+), 272 deletions(-) create mode 100644 Modules/Sources/WooFoundation/Utilities/WaitingTimeTracker.swift create mode 100644 Modules/Tests/WooFoundationTests/Utilities/WaitingTimeTrackerTests.swift delete mode 100644 WooCommerce/Classes/Analytics/WaitingTimeTracker.swift delete mode 100644 WooCommerce/WooCommerceTests/System/WaitingTimeTrackerTests.swift diff --git a/Modules/Sources/WooFoundation/Utilities/WaitingTimeTracker.swift b/Modules/Sources/WooFoundation/Utilities/WaitingTimeTracker.swift new file mode 100644 index 00000000000..aba1aae82f6 --- /dev/null +++ b/Modules/Sources/WooFoundation/Utilities/WaitingTimeTracker.swift @@ -0,0 +1,86 @@ +import Foundation + +/// Tracks the waiting time for a given scenario, allowing to evaluate as analytics +/// how much time in seconds it took between the init and `end` function call +/// +public class WaitingTimeTracker { + private let trackScenario: WooAnalyticsEvent.WaitingTime.Scenario + private let currentTimestampSeconds: () -> TimeInterval + private let waitingStartedTimestamp: TimeInterval + + public enum TrackingUnit { + case seconds + case milliseconds + } + + public init(trackScenario: WooAnalyticsEvent.WaitingTime.Scenario, + currentTimestampSeconds: @escaping () -> TimeInterval = { Date().timeIntervalSince1970 } + ) { + self.trackScenario = trackScenario + self.currentTimestampSeconds = currentTimestampSeconds + waitingStartedTimestamp = currentTimestampSeconds() + } + + /// Default `end()` method to preserve interface compatibility. By default, tracks in `.seconds` + /// - Returns: The analytics event to be tracked. + /// + public func end() -> WooAnalyticsEvent { + end(using: .seconds) + } + + /// End the waiting time by evaluating the elapsed time from the init, + /// and returning an analytics event for tracking. + /// + /// - Parameter trackingUnit: Defines whether the elapsed time should be tracked in `.seconds` or `.milliseconds` (default is `.seconds`). + /// - Returns: The analytics event to be tracked. + /// + public func end(using trackingUnit: TrackingUnit = .seconds) -> WooAnalyticsEvent { + let elapsedTime = calculateElapsedTime(in: trackingUnit) + return .WaitingTime.waitingFinished(scenario: trackScenario, elapsedTime: elapsedTime) + } + + /// Calculates elapsed time in the specified tracking unit. + /// + private func calculateElapsedTime(in trackingUnit: TrackingUnit) -> TimeInterval { + let elapsedTime = currentTimestampSeconds() - waitingStartedTimestamp + return trackingUnit == .milliseconds ? elapsedTime * 1000 : elapsedTime + } +} + +// MARK: - Waiting Time measurement +// +public extension WooAnalyticsEvent { + enum WaitingTime { + /// Possible Waiting time scenarios + public enum Scenario { + case orderDetails + case dashboardTopPerformers + case dashboardMainStats + case analyticsHub + case appStartup + case pointOfSaleLoaded + } + + private enum Keys { + static let waitingTime = "waiting_time" + static let millisecondsTimeElapsedInSplashScreen = "milliseconds_time_elapsed_in_splash_screen" + } + + static func waitingFinished(scenario: Scenario, elapsedTime: TimeInterval) -> WooAnalyticsEvent { + switch scenario { + case .orderDetails: + return WooAnalyticsEvent(statName: .orderDetailWaitingTimeLoaded, properties: [Keys.waitingTime: elapsedTime]) + case .dashboardTopPerformers: + return WooAnalyticsEvent(statName: .dashboardTopPerformersWaitingTimeLoaded, properties: [Keys.waitingTime: elapsedTime]) + case .dashboardMainStats: + return WooAnalyticsEvent(statName: .dashboardMainStatsWaitingTimeLoaded, properties: [Keys.waitingTime: elapsedTime]) + case .analyticsHub: + return WooAnalyticsEvent(statName: .analyticsHubWaitingTimeLoaded, properties: [Keys.waitingTime: elapsedTime]) + case .appStartup: + return WooAnalyticsEvent(statName: .applicationOpenedWaitingTimeLoaded, properties: [Keys.waitingTime: elapsedTime]) + case .pointOfSaleLoaded: + return WooAnalyticsEvent(statName: .pointOfSaleLoaded, properties: [Keys.millisecondsTimeElapsedInSplashScreen: elapsedTime]) + } + } + } +} diff --git a/Modules/Tests/WooFoundationTests/Utilities/WaitingTimeTrackerTests.swift b/Modules/Tests/WooFoundationTests/Utilities/WaitingTimeTrackerTests.swift new file mode 100644 index 00000000000..ab579ba4f01 --- /dev/null +++ b/Modules/Tests/WooFoundationTests/Utilities/WaitingTimeTrackerTests.swift @@ -0,0 +1,113 @@ +import XCTest +@testable import WooFoundation + +/// WaitingTimeTracker Unit Tests +/// +final class WaitingTimeTrackerTests: XCTestCase { + func testTimeElapsedEvaluationIsCorrect() { + var currentTimeCallCounter = 0.0 + + // Given + let waitingTracker = WaitingTimeTracker(trackScenario: .orderDetails) { + currentTimeCallCounter += 1 + return currentTimeCallCounter * 10 + } + + // When + let event = waitingTracker.end() + + // Then + XCTAssertEqual(event.properties["waiting_time"] as? TimeInterval, 10.0) + } + + func testOrderDetailsTrackScenarioTriggersExpectedAnalyticsStat() { + // Given + let waitingTracker = WaitingTimeTracker(trackScenario: .orderDetails, currentTimestampSeconds: { 0 }) + + // When + let event = waitingTracker.end() + + // Then + XCTAssertEqual(event.statName.rawValue, WooAnalyticsStat.orderDetailWaitingTimeLoaded.rawValue) + } + + func testTopPerformersTrackScenarioTriggersExpectedAnalyticsStat() { + // Given + let waitingTracker = WaitingTimeTracker(trackScenario: .dashboardTopPerformers, + currentTimestampSeconds: { 0 } + ) + + // When + let event = waitingTracker.end() + + // Then + XCTAssertEqual(event.statName.rawValue, WooAnalyticsStat.dashboardTopPerformersWaitingTimeLoaded.rawValue) + } + + func testMainStatsTrackScenarioTriggersExpectedAnalyticsStat() { + // Given + let waitingTracker = WaitingTimeTracker(trackScenario: .dashboardMainStats, + currentTimestampSeconds: { 0 } + ) + + // When + let event = waitingTracker.end() + + // Then + XCTAssertEqual(event.statName.rawValue, WooAnalyticsStat.dashboardMainStatsWaitingTimeLoaded.rawValue) + } + + func test_analytics_hub_track_scenario_triggers_expected_analytics_stat() { + // Given + let waitingTracker = WaitingTimeTracker(trackScenario: .analyticsHub, + currentTimestampSeconds: { 0 } + ) + + // When + let event = waitingTracker.end() + + // Then + XCTAssertEqual(event.statName.rawValue, WooAnalyticsStat.analyticsHubWaitingTimeLoaded.rawValue) + } + + func test_appStartup_track_scenario_triggers_expected_analytics_stat() { + // Given + let waitingTracker = WaitingTimeTracker(trackScenario: .appStartup, + currentTimestampSeconds: { 0 } + ) + + // When + let event = waitingTracker.end() + + // Then + XCTAssertEqual(event.statName.rawValue, WooAnalyticsStat.applicationOpenedWaitingTimeLoaded.rawValue) + } + + func test_timeElapsed_evaluation_in_milliseconds_is_correct() { + // Given + var currentTimeCallCounter = 0.0 + let expectedReceivedWaitingTime = 10_000.0 // 10s * 1000 ms + let waitingTracker = WaitingTimeTracker(trackScenario: .orderDetails) { + currentTimeCallCounter += 1 + return currentTimeCallCounter * 10 + } + + // When + let event = waitingTracker.end(using: .milliseconds) + + // Then + XCTAssertEqual(event.properties["waiting_time"] as? TimeInterval, expectedReceivedWaitingTime) + } + + func test_track_scenario_triggers_expected_analytics_stat_in_milliseconds() { + // Given + let waitingTracker = WaitingTimeTracker(trackScenario: .pointOfSaleLoaded, + currentTimestampSeconds: { 0 }) + + // When + let event = waitingTracker.end(using: .milliseconds) + + // Then + XCTAssertEqual(event.statName.rawValue, WooAnalyticsStat.pointOfSaleLoaded.rawValue) + } +} diff --git a/WooCommerce/Classes/Analytics/AppStartupWaitingTimeTracker.swift b/WooCommerce/Classes/Analytics/AppStartupWaitingTimeTracker.swift index 13b32c43fdd..6444b832ba8 100644 --- a/WooCommerce/Classes/Analytics/AppStartupWaitingTimeTracker.swift +++ b/WooCommerce/Classes/Analytics/AppStartupWaitingTimeTracker.swift @@ -1,11 +1,12 @@ import Foundation import Yosemite +import class WooFoundation.WaitingTimeTracker import protocol WooFoundation.Analytics /// Tracks the waiting time for app startup, allowing to evaluate as analytics /// how much time in seconds it took between the init and the final `end(action:)` function call. /// -final class AppStartupWaitingTimeTracker: WaitingTimeTracker { +final class AppStartupWaitingTimeTracker { /// All actions tracked in the app startup waiting time. /// @@ -19,10 +20,13 @@ final class AppStartupWaitingTimeTracker: WaitingTimeTracker { /// Represents all of the app startup actions waiting to be completed. /// private(set) var startupActionsPending = StartupAction.allCases + private let analyticsService: Analytics + private let waitingTimeTracker: WaitingTimeTracker init(analyticsService: Analytics = ServiceLocator.analytics, currentTimestampSeconds: @escaping () -> TimeInterval = { Date().timeIntervalSince1970 }) { - super.init(trackScenario: .appStartup, analyticsService: analyticsService, currentTimestampSeconds: currentTimestampSeconds) + self.analyticsService = analyticsService + self.waitingTimeTracker = WaitingTimeTracker(trackScenario: .appStartup, currentTimestampSeconds: currentTimestampSeconds) } /// Ends the waiting time for the provided startup action. @@ -39,7 +43,7 @@ final class AppStartupWaitingTimeTracker: WaitingTimeTracker { // If all actions completed without any errors, send the analytics event. if startupActionsPending.isEmpty { - super.end() + analyticsService.track(event: waitingTimeTracker.end()) } } @@ -48,7 +52,7 @@ final class AppStartupWaitingTimeTracker: WaitingTimeTracker { /// This can be used to stop tracking in scenarios that would skew the waiting time analysis. /// For example, when the app is backgrounded or a startup action has an API error or network connection error. /// - override func end() { + func endWithoutTracking() { startupActionsPending.removeAll() } } diff --git a/WooCommerce/Classes/Analytics/WaitingTimeTracker.swift b/WooCommerce/Classes/Analytics/WaitingTimeTracker.swift deleted file mode 100644 index 4fbaaf8362d..00000000000 --- a/WooCommerce/Classes/Analytics/WaitingTimeTracker.swift +++ /dev/null @@ -1,52 +0,0 @@ -import Foundation -import Combine -import protocol WooFoundation.Analytics - -/// Tracks the waiting time for a given scenario, allowing to evaluate as analytics -/// how much time in seconds it took between the init and `end` function call -/// -class WaitingTimeTracker { - private let trackScenario: WooAnalyticsEvent.WaitingTime.Scenario - private let currentTimestampSeconds: () -> TimeInterval - private let analyticsService: Analytics - private let waitingStartedTimestamp: TimeInterval - - enum TrackingUnit { - case seconds - case milliseconds - } - - init(trackScenario: WooAnalyticsEvent.WaitingTime.Scenario, - analyticsService: Analytics = ServiceLocator.analytics, - currentTimestampSeconds: @escaping () -> TimeInterval = { Date().timeIntervalSince1970 } - ) { - self.trackScenario = trackScenario - self.analyticsService = analyticsService - self.currentTimestampSeconds = currentTimestampSeconds - waitingStartedTimestamp = currentTimestampSeconds() - } - - /// Default `end()` method to preserve interface compatibility. By default, tracks in `.seconds` - /// - func end() { - end(using: .seconds) - } - - /// End the waiting time by evaluating the elapsed time from the init, - /// and sending it as an analytics event. - /// - /// - Parameter trackingUnit: Defines whether the elapsed time should be tracked in `.seconds` or `.milliseconds` (default is `.seconds`). - /// - func end(using trackingUnit: TrackingUnit = .seconds) { - let elapsedTime = calculateElapsedTime(in: trackingUnit) - let analyticsEvent = WooAnalyticsEvent.WaitingTime.waitingFinished(scenario: trackScenario, elapsedTime: elapsedTime) - analyticsService.track(event: analyticsEvent) - } - - /// Calculates elapsed time in the specified tracking unit. - /// - private func calculateElapsedTime(in trackingUnit: TrackingUnit) -> TimeInterval { - let elapsedTime = currentTimestampSeconds() - waitingStartedTimestamp - return trackingUnit == .milliseconds ? elapsedTime * 1000 : elapsedTime - } -} diff --git a/WooCommerce/Classes/Analytics/WooAnalyticsEvent+WooApp.swift b/WooCommerce/Classes/Analytics/WooAnalyticsEvent+WooApp.swift index 1f2f319f601..4044cf056d4 100644 --- a/WooCommerce/Classes/Analytics/WooAnalyticsEvent+WooApp.swift +++ b/WooCommerce/Classes/Analytics/WooAnalyticsEvent+WooApp.swift @@ -2565,45 +2565,6 @@ extension WooAnalyticsEvent { } } - -// MARK: - Waiting Time measurement -// -extension WooAnalyticsEvent { - enum WaitingTime { - /// Possible Waiting time scenarios - enum Scenario { - case orderDetails - case dashboardTopPerformers - case dashboardMainStats - case analyticsHub - case appStartup - case pointOfSaleLoaded - } - - private enum Keys { - static let waitingTime = "waiting_time" - static let millisecondsTimeElapsedInSplashScreen = "milliseconds_time_elapsed_in_splash_screen" - } - - static func waitingFinished(scenario: Scenario, elapsedTime: TimeInterval) -> WooAnalyticsEvent { - switch scenario { - case .orderDetails: - return WooAnalyticsEvent(statName: .orderDetailWaitingTimeLoaded, properties: [Keys.waitingTime: elapsedTime]) - case .dashboardTopPerformers: - return WooAnalyticsEvent(statName: .dashboardTopPerformersWaitingTimeLoaded, properties: [Keys.waitingTime: elapsedTime]) - case .dashboardMainStats: - return WooAnalyticsEvent(statName: .dashboardMainStatsWaitingTimeLoaded, properties: [Keys.waitingTime: elapsedTime]) - case .analyticsHub: - return WooAnalyticsEvent(statName: .analyticsHubWaitingTimeLoaded, properties: [Keys.waitingTime: elapsedTime]) - case .appStartup: - return WooAnalyticsEvent(statName: .applicationOpenedWaitingTimeLoaded, properties: [Keys.waitingTime: elapsedTime]) - case .pointOfSaleLoaded: - return WooAnalyticsEvent(statName: .pointOfSaleLoaded, properties: [Keys.millisecondsTimeElapsedInSplashScreen: elapsedTime]) - } - } - } -} - // MARK: - Site picker // extension WooAnalyticsEvent { diff --git a/WooCommerce/Classes/AppDelegate.swift b/WooCommerce/Classes/AppDelegate.swift index cb6bf43d957..ea0c6de3312 100644 --- a/WooCommerce/Classes/AppDelegate.swift +++ b/WooCommerce/Classes/AppDelegate.swift @@ -436,7 +436,7 @@ private extension AppDelegate { /// Cancel the app startup waiting time tracker /// func cancelStartupWaitingTimeTracker() { - ServiceLocator.startupWaitingTimeTracker.end() + ServiceLocator.startupWaitingTimeTracker.endWithoutTracking() } func handleLaunchArguments() { diff --git a/WooCommerce/Classes/POS/Presentation/PointOfSaleDashboardView.swift b/WooCommerce/Classes/POS/Presentation/PointOfSaleDashboardView.swift index 82ab5c01c28..949f5c81dd6 100644 --- a/WooCommerce/Classes/POS/Presentation/PointOfSaleDashboardView.swift +++ b/WooCommerce/Classes/POS/Presentation/PointOfSaleDashboardView.swift @@ -4,6 +4,7 @@ import WooFoundation struct PointOfSaleDashboardView: View { @Environment(PointOfSaleAggregateModel.self) private var posModel @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @Environment(\.posAnalytics) private var analytics @Environment(\.posExternalViews) private var externalViews @State private var showExitPOSModal: Bool = false @@ -230,7 +231,8 @@ private extension PointOfSaleDashboardView { func trackElapsedTimeForInitialLoadingState() { if let waitingTimeTracker { - waitingTimeTracker.end(using: .milliseconds) + let event = waitingTimeTracker.end(using: .milliseconds) + analytics.track(event: event) self.waitingTimeTracker = nil } } diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Analytics Hub/AnalyticsHubViewModel.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Analytics Hub/AnalyticsHubViewModel.swift index 464ebc95721..236112e9f35 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Analytics Hub/AnalyticsHubViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Analytics Hub/AnalyticsHubViewModel.swift @@ -2,6 +2,7 @@ import Foundation import Yosemite import Combine import class UIKit.UIColor +import class WooFoundation.WaitingTimeTracker import protocol Storage.StorageManagerType import protocol WooFoundation.Analytics @@ -278,9 +279,9 @@ final class AnalyticsHubViewModel: ObservableObject { func updateData(for cards: [AnalyticsCard.CardType]? = nil) async { let cardsNeedingData = cards ?? enabledCards do { - let tracker = WaitingTimeTracker(trackScenario: .analyticsHub, analyticsService: analytics) + let tracker = WaitingTimeTracker(trackScenario: .analyticsHub) try await retrieveData(for: cardsNeedingData) - tracker.end() + analytics.track(event: tracker.end()) } catch is AnalyticsHubTimeRangeSelection.TimeRangeGeneratorError { dismissNotice = Notice(title: Localization.timeRangeGeneratorError, feedbackType: .error) ServiceLocator.analytics.track(event: .AnalyticsHub.dateRangeSelectionFailed(for: timeRangeSelectionType)) diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Onboarding/StoreOnboardingViewModel.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Onboarding/StoreOnboardingViewModel.swift index d0a0538c438..0d568827eda 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Onboarding/StoreOnboardingViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Onboarding/StoreOnboardingViewModel.swift @@ -237,7 +237,7 @@ private extension StoreOnboardingViewModel { return StoreOnboardingTask(isComplete: true, type: .launchStore) })) case .failure(let error): - self?.waitingTimeTracker.end() // Stop the tracker if there is an error. + self?.waitingTimeTracker.endWithoutTracking() // Stop the tracker if there is an error. return continuation.resume(throwing: error) } }) diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/StoreStats/StorePerformanceViewModel.swift b/WooCommerce/Classes/ViewRelated/Dashboard/StoreStats/StorePerformanceViewModel.swift index 0007b6773a6..0f6e9e814f4 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/StoreStats/StorePerformanceViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/StoreStats/StorePerformanceViewModel.swift @@ -287,7 +287,9 @@ private extension StorePerformanceViewModel { .receive(on: DispatchQueue.global(qos: .background)) .sink { [weak self] error in guard let self else { return } - waitingTracker?.end() + if let event = waitingTracker?.end() { + analytics.track(event: event) + } analytics.track(event: .Dashboard.dashboardMainStatsLoaded(timeRange: timeRange)) if let error { analytics.track(event: .DynamicDashboard.cardLoadingFailed(type: .performance, error: error)) @@ -488,7 +490,7 @@ private extension StorePerformanceViewModel { /// func trackDashboardStatsSyncComplete(withError error: Error? = nil) { guard error == nil else { // Stop the tracker if there is an error. - ServiceLocator.startupWaitingTimeTracker.end() + ServiceLocator.startupWaitingTimeTracker.endWithoutTracking() return } ServiceLocator.startupWaitingTimeTracker.end(action: .syncDashboardStats) diff --git a/WooCommerce/Classes/ViewRelated/Orders/Order Details/OrderDetailsViewController.swift b/WooCommerce/Classes/ViewRelated/Orders/Order Details/OrderDetailsViewController.swift index efb266535ea..c749ee1efc1 100644 --- a/WooCommerce/Classes/ViewRelated/Orders/Order Details/OrderDetailsViewController.swift +++ b/WooCommerce/Classes/ViewRelated/Orders/Order Details/OrderDetailsViewController.swift @@ -96,7 +96,7 @@ final class OrderDetailsViewController: UIViewController { super.viewWillAppear(animated) let waitingTracker = WaitingTimeTracker(trackScenario: .orderDetails) syncEverything { [weak self] in - waitingTracker.end() + ServiceLocator.analytics.track(event: waitingTracker.end()) self?.topLoaderView.isHidden = true diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index 35dcc9f2288..9765213d18f 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -1543,7 +1543,6 @@ 4A690C262BA7A08A00A8E0C5 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 4A690C252BA7A08500A8E0C5 /* PrivacyInfo.xcprivacy */; }; 4A690C282BA7A5B400A8E0C5 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 4A690C272BA7A3F800A8E0C5 /* PrivacyInfo.xcprivacy */; }; 532842FC64B572D4545BD98E /* OrderFormCustomerNoteViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53284C9FD06F2BDABC554BEE /* OrderFormCustomerNoteViewModel.swift */; }; - 532846FAFFFCA93169B5E0BC /* WaitingTimeTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53284FB62FF7F94F18F0D3FF /* WaitingTimeTracker.swift */; }; 53284E9AB1C65FD79E803694 /* EUShippingNoticeTopBannerFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53284F4A66A725F479CD9584 /* EUShippingNoticeTopBannerFactory.swift */; }; 570AAB052472FACB00516C0C /* OrderDetailsDataSourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 570AAB042472FACB00516C0C /* OrderDetailsDataSourceTests.swift */; }; 571CDD5A250ACC470076B8CC /* UITableViewDiffableDataSource+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 571CDD59250ACC470076B8CC /* UITableViewDiffableDataSource+Helpers.swift */; }; @@ -2008,7 +2007,6 @@ B5FD111621D3F13700560344 /* BordersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5FD111521D3F13700560344 /* BordersView.swift */; }; B60B5026292D308A00178C26 /* AnalyticsTimeRangeCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = B60B5025292D308A00178C26 /* AnalyticsTimeRangeCard.swift */; }; B61F212A2AA13CA700B0C8EB /* ShippingLabelHazmatCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = B61F21292AA13CA700B0C8EB /* ShippingLabelHazmatCategory.swift */; }; - B622BC74289CF19400B10CEC /* WaitingTimeTrackerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B622BC73289CF19400B10CEC /* WaitingTimeTrackerTests.swift */; }; B626C71B287659D60083820C /* CustomFieldsListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B626C71A287659D60083820C /* CustomFieldsListView.swift */; }; B63AAF4B254AD2C6000B28A2 /* URL+SurveyViewControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B63AAF4A254AD2C6000B28A2 /* URL+SurveyViewControllerTests.swift */; }; B63D9009293E56E300BB5C9D /* AnalyticsHubQuarterToDateRangeData.swift in Sources */ = {isa = PBXBuildFile; fileRef = B63D9008293E56E300BB5C9D /* AnalyticsHubQuarterToDateRangeData.swift */; }; @@ -4744,7 +4742,6 @@ 4A690C272BA7A3F800A8E0C5 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 53284C9FD06F2BDABC554BEE /* OrderFormCustomerNoteViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OrderFormCustomerNoteViewModel.swift; sourceTree = ""; }; 53284F4A66A725F479CD9584 /* EUShippingNoticeTopBannerFactory.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EUShippingNoticeTopBannerFactory.swift; sourceTree = ""; }; - 53284FB62FF7F94F18F0D3FF /* WaitingTimeTracker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WaitingTimeTracker.swift; sourceTree = ""; }; 570AAB042472FACB00516C0C /* OrderDetailsDataSourceTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = OrderDetailsDataSourceTests.swift; path = "Order Details/OrderDetailsDataSourceTests.swift"; sourceTree = ""; }; 571CDD59250ACC470076B8CC /* UITableViewDiffableDataSource+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITableViewDiffableDataSource+Helpers.swift"; sourceTree = ""; }; 571FDDAD24C768DC00D486A5 /* MockZendeskManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockZendeskManager.swift; sourceTree = ""; }; @@ -5238,7 +5235,6 @@ B5FD111521D3F13700560344 /* BordersView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BordersView.swift; sourceTree = ""; }; B60B5025292D308A00178C26 /* AnalyticsTimeRangeCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsTimeRangeCard.swift; sourceTree = ""; }; B61F21292AA13CA700B0C8EB /* ShippingLabelHazmatCategory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShippingLabelHazmatCategory.swift; sourceTree = ""; }; - B622BC73289CF19400B10CEC /* WaitingTimeTrackerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaitingTimeTrackerTests.swift; sourceTree = ""; }; B626C71A287659D60083820C /* CustomFieldsListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomFieldsListView.swift; sourceTree = ""; }; B63AAF4A254AD2C6000B28A2 /* URL+SurveyViewControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+SurveyViewControllerTests.swift"; sourceTree = ""; }; B63D9008293E56E300BB5C9D /* AnalyticsHubQuarterToDateRangeData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsHubQuarterToDateRangeData.swift; sourceTree = ""; }; @@ -10250,7 +10246,6 @@ 02C3FACD282A93020095440A /* WooAnalyticsEvent+Dashboard.swift */, 0247F511286F73EA009C177E /* WooAnalyticsEvent+ImageUpload.swift */, 02AC30CE2888EC8100146A25 /* WooAnalyticsEvent+LoginOnboarding.swift */, - 53284FB62FF7F94F18F0D3FF /* WaitingTimeTracker.swift */, CEC8188B2A3B7C8B00459843 /* AppStartupWaitingTimeTracker.swift */, 0263E3BA290BB21800E5F88F /* WooAnalyticsEvent+StoreCreation.swift */, EE57C11E297E742200BC31E7 /* WooAnalyticsEvent+ApplicationPassword.swift */, @@ -10706,7 +10701,6 @@ 9379E1A22255365F006A6BE4 /* TestingMode.storyboard */, 746791622108D7C0007CF1DC /* WooAnalyticsTests.swift */, 26B119C124D1CD3500FED5C7 /* WooConstantsTests.swift */, - B622BC73289CF19400B10CEC /* WaitingTimeTrackerTests.swift */, CEC8188D2A3C75DD00459843 /* AppStartupWaitingTimeTrackerTests.swift */, ); path = System; @@ -17132,7 +17126,6 @@ 01929C362CEF6D6E006C79ED /* CardPresentModalNonRetryableErrorWithoutEmail.swift in Sources */, 016582E72E789409001DBB6F /* POSIneligibleReason.swift in Sources */, 532842FC64B572D4545BD98E /* OrderFormCustomerNoteViewModel.swift in Sources */, - 532846FAFFFCA93169B5E0BC /* WaitingTimeTracker.swift in Sources */, 53284E9AB1C65FD79E803694 /* EUShippingNoticeTopBannerFactory.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -17241,7 +17234,6 @@ DE4D23B029B1D02A003A4B5D /* WPCom2FALoginViewModelTests.swift in Sources */, 03B9E52F2A150EED005C77F5 /* MockCardReaderSupportDeterminer.swift in Sources */, D8C11A6022E2479800D4A88D /* OrderPaymentDetailsViewModelTests.swift in Sources */, - B622BC74289CF19400B10CEC /* WaitingTimeTrackerTests.swift in Sources */, 023EC2E224DA8BAB0021DA91 /* MockProductSKUValidationStoresManager.swift in Sources */, 26FE09E124DB8FA000B9BDF5 /* SurveyCoordinatorControllerTests.swift in Sources */, CE91BEA029FBE9E100B6E1AF /* QuantityRulesViewModelTests.swift in Sources */, diff --git a/WooCommerce/WooCommerceTests/System/AppStartupWaitingTimeTrackerTests.swift b/WooCommerce/WooCommerceTests/System/AppStartupWaitingTimeTrackerTests.swift index 3b8332c1da3..d883ae4f8dc 100644 --- a/WooCommerce/WooCommerceTests/System/AppStartupWaitingTimeTrackerTests.swift +++ b/WooCommerce/WooCommerceTests/System/AppStartupWaitingTimeTrackerTests.swift @@ -56,7 +56,7 @@ final class AppStartupWaitingTimeTrackerTests: XCTestCase { tracker = AppStartupWaitingTimeTracker(analyticsService: analytics) // When - tracker.end() + tracker.endWithoutTracking() completeAllStartupActions() // Then diff --git a/WooCommerce/WooCommerceTests/System/WaitingTimeTrackerTests.swift b/WooCommerce/WooCommerceTests/System/WaitingTimeTrackerTests.swift deleted file mode 100644 index c1d76ab77f7..00000000000 --- a/WooCommerce/WooCommerceTests/System/WaitingTimeTrackerTests.swift +++ /dev/null @@ -1,148 +0,0 @@ -import XCTest -import protocol WooFoundation.Analytics -import protocol WooFoundation.AnalyticsProvider -@testable import WooCommerce - -/// WaitingTimeTracker Unit Tests -/// -class WaitingTimeTrackerTests: XCTestCase { - private var testAnalytics = TestAnalytics() - - func testTimeElapsedEvaluationIsCorrect() { - var currentTimeCallCounter = 0.0 - - // Given - let waitingTracker = WaitingTimeTracker(trackScenario: .orderDetails, analyticsService: testAnalytics) { - currentTimeCallCounter += 1 - return currentTimeCallCounter * 10 - } - - // When - waitingTracker.end() - - // Then - XCTAssertEqual(testAnalytics.lastReceivedWaitingTime, 10.0) - } - - func testOrderDetailsTrackScenarioTriggersExpectedAnalyticsStat() { - // Given - let waitingTracker = WaitingTimeTracker(trackScenario: .orderDetails, analyticsService: testAnalytics, currentTimestampSeconds: { 0 }) - - // When - waitingTracker.end() - - // Then - XCTAssertEqual(testAnalytics.lastReceivedEventName, WooAnalyticsStat.orderDetailWaitingTimeLoaded.rawValue) - } - - func testTopPerformersTrackScenarioTriggersExpectedAnalyticsStat() { - // Given - let waitingTracker = WaitingTimeTracker(trackScenario: .dashboardTopPerformers, - analyticsService: testAnalytics, - currentTimestampSeconds: { 0 } - ) - - // When - waitingTracker.end() - - // Then - XCTAssertEqual(testAnalytics.lastReceivedEventName, WooAnalyticsStat.dashboardTopPerformersWaitingTimeLoaded.rawValue) - } - - func testMainStatsTrackScenarioTriggersExpectedAnalyticsStat() { - // Given - let waitingTracker = WaitingTimeTracker(trackScenario: .dashboardMainStats, - analyticsService: testAnalytics, - currentTimestampSeconds: { 0 } - ) - - // When - waitingTracker.end() - - // Then - XCTAssertEqual(testAnalytics.lastReceivedEventName, WooAnalyticsStat.dashboardMainStatsWaitingTimeLoaded.rawValue) - } - - func test_analytics_hub_track_scenario_triggers_expected_analytics_stat() { - // Given - let waitingTracker = WaitingTimeTracker(trackScenario: .analyticsHub, - analyticsService: testAnalytics, - currentTimestampSeconds: { 0 } - ) - - // When - waitingTracker.end() - - // Then - XCTAssertEqual(testAnalytics.lastReceivedEventName, WooAnalyticsStat.analyticsHubWaitingTimeLoaded.rawValue) - } - - func test_appStartup_track_scenario_triggers_expected_analytics_stat() { - // Given - let waitingTracker = WaitingTimeTracker(trackScenario: .appStartup, - analyticsService: testAnalytics, - currentTimestampSeconds: { 0 } - ) - - // When - waitingTracker.end() - - // Then - XCTAssertEqual(testAnalytics.lastReceivedEventName, WooAnalyticsStat.applicationOpenedWaitingTimeLoaded.rawValue) - } - - func test_timeElapsed_evaluation_in_milliseconds_is_correct() { - // Given - var currentTimeCallCounter = 0.0 - let expectedReceivedWaitingTime = 10_000.0 // 10s * 1000 ms - let waitingTracker = WaitingTimeTracker(trackScenario: .orderDetails, - analyticsService: testAnalytics) { - currentTimeCallCounter += 1 - return currentTimeCallCounter * 10 - } - - // When - waitingTracker.end(using: .milliseconds) - - // Then - XCTAssertEqual(testAnalytics.lastReceivedWaitingTime, expectedReceivedWaitingTime) - } - - func test_track_scenario_triggers_expected_analytics_stat_in_milliseconds() { - // Given - let waitingTracker = WaitingTimeTracker(trackScenario: .pointOfSaleLoaded, - analyticsService: testAnalytics, - currentTimestampSeconds: { 0 }) - - // When - waitingTracker.end(using: .milliseconds) - - // Then - XCTAssertEqual(testAnalytics.lastReceivedEventName, WooAnalyticsStat.pointOfSaleLoaded.rawValue) - } - - class TestAnalytics: Analytics { - var lastReceivedEventName: String? = nil - var lastReceivedWaitingTime: TimeInterval? = nil - - // MARK: - Protocol conformance - - func initialize() { - } - - func track(_ eventName: String, properties: [AnyHashable: Any]?, error: Error?) { - lastReceivedEventName = eventName - lastReceivedWaitingTime = properties?["waiting_time"] as? TimeInterval - } - - func refreshUserData() { - } - - func setUserHasOptedOut(_ optedOut: Bool) { - userHasOptedIn = !optedOut - } - - var userHasOptedIn: Bool = true - private(set) var analyticsProvider: AnalyticsProvider = MockAnalyticsProvider() - } -} diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Settings/Privacy/PrivacyBannerViewModelTest.swift b/WooCommerce/WooCommerceTests/ViewRelated/Settings/Privacy/PrivacyBannerViewModelTest.swift index 03d84ad8c5c..c8ead48ca0b 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Settings/Privacy/PrivacyBannerViewModelTest.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Settings/Privacy/PrivacyBannerViewModelTest.swift @@ -8,7 +8,7 @@ import TestKit func test_analytics_state_has_correct_initial_value_when_user_has_opt_out() { // Given - let analytics = WaitingTimeTrackerTests.TestAnalytics() + let analytics = TestAnalytics() analytics.userHasOptedIn = false // When @@ -20,7 +20,7 @@ import TestKit func test_analytics_state_has_correct_initial_value_when_user_has_opt_in() { // Given - let analytics = WaitingTimeTrackerTests.TestAnalytics() + let analytics = TestAnalytics() analytics.userHasOptedIn = true // When @@ -32,7 +32,7 @@ import TestKit func test_submit_changes_on_wpcom_account_triggers_network_request_and_updates_loading_state() { // Given - let analytics = WaitingTimeTrackerTests.TestAnalytics() + let analytics = TestAnalytics() analytics.userHasOptedIn = true let stores = MockStoresManager(sessionManager: .makeForTesting(authenticated: true, isWPCom: true, displayName: "Store")) @@ -62,7 +62,7 @@ import TestKit func test_submit_changes_using_wpcom_account_calls_completion_block() { // Given - let analytics = WaitingTimeTrackerTests.TestAnalytics() + let analytics = TestAnalytics() analytics.userHasOptedIn = true let stores = MockStoresManager(sessionManager: .makeForTesting(authenticated: true, isWPCom: true, displayName: "Store")) @@ -92,7 +92,7 @@ import TestKit func test_submit_changes_using_non_wpcom_account_calls_completion_block() { // Given - let analytics = WaitingTimeTrackerTests.TestAnalytics() + let analytics = TestAnalytics() analytics.userHasOptedIn = true let stores = MockStoresManager(sessionManager: .makeForTesting(authenticated: true, isWPCom: false)) @@ -113,7 +113,7 @@ import TestKit @MainActor func test_tapping_go_to_settings_tracks_analytic_event() async { // Given - let analytics = WaitingTimeTrackerTests.TestAnalytics() + let analytics = TestAnalytics() let stores = MockStoresManager(sessionManager: .makeForTesting(authenticated: true, isWPCom: false)) let viewModel = PrivacyBannerViewModel(analytics: analytics, stores: stores, onCompletion: { _ in }) @@ -126,7 +126,7 @@ import TestKit @MainActor func test_tapping_go_to_save_tracks_analytic_event() async { // Given - let analytics = WaitingTimeTrackerTests.TestAnalytics() + let analytics = TestAnalytics() let stores = MockStoresManager(sessionManager: .makeForTesting(authenticated: true, isWPCom: false)) let viewModel = PrivacyBannerViewModel(analytics: analytics, stores: stores, onCompletion: { _ in }) diff --git a/WooCommerce/WooCommerceTests/ViewRelated/Settings/Privacy/UpdateAnalyticsSettingsUseCaseTests.swift b/WooCommerce/WooCommerceTests/ViewRelated/Settings/Privacy/UpdateAnalyticsSettingsUseCaseTests.swift index beece8a8c1f..420a5ec2e82 100644 --- a/WooCommerce/WooCommerceTests/ViewRelated/Settings/Privacy/UpdateAnalyticsSettingsUseCaseTests.swift +++ b/WooCommerce/WooCommerceTests/ViewRelated/Settings/Privacy/UpdateAnalyticsSettingsUseCaseTests.swift @@ -1,5 +1,7 @@ import XCTest import TestKit +import protocol WooFoundation.Analytics +import protocol WooFoundation.AnalyticsProvider @testable import WooCommerce @testable import Yosemite @@ -17,7 +19,7 @@ final class UpdateAnalyticsSettingsUseCaseTests: XCTestCase { break } } - let analytics = WaitingTimeTrackerTests.TestAnalytics() + let analytics = TestAnalytics() let userDefaults = try XCTUnwrap(UserDefaults(suiteName: "TestingSuite")) // When @@ -40,7 +42,7 @@ final class UpdateAnalyticsSettingsUseCaseTests: XCTestCase { break } } - let analytics = WaitingTimeTrackerTests.TestAnalytics() + let analytics = TestAnalytics() let userDefaults = try XCTUnwrap(UserDefaults(suiteName: "TestingSuite")) // When @@ -55,7 +57,7 @@ final class UpdateAnalyticsSettingsUseCaseTests: XCTestCase { @MainActor func test_using_a_non_wpcom_account_opt_in_analytics_updates_analytics_state() async throws { // Given let stores = MockStoresManager(sessionManager: .makeForTesting(authenticated: true, isWPCom: false)) - let analytics = WaitingTimeTrackerTests.TestAnalytics() + let analytics = TestAnalytics() let userDefaults = try XCTUnwrap(UserDefaults(suiteName: "TestingSuite")) // When @@ -70,7 +72,7 @@ final class UpdateAnalyticsSettingsUseCaseTests: XCTestCase { @MainActor func test_using_a_non_wpcom_account_opt_out_analytics_updates_analytics_state() async throws { // Given let stores = MockStoresManager(sessionManager: .makeForTesting(authenticated: true, isWPCom: false)) - let analytics = WaitingTimeTrackerTests.TestAnalytics() + let analytics = TestAnalytics() let userDefaults = try XCTUnwrap(UserDefaults(suiteName: "TestingSuite")) // When @@ -94,7 +96,7 @@ final class UpdateAnalyticsSettingsUseCaseTests: XCTestCase { break } } - let analytics = WaitingTimeTrackerTests.TestAnalytics() + let analytics = TestAnalytics() let userDefaults = try XCTUnwrap(UserDefaults(suiteName: "TestingSuite")) analytics.setUserHasOptedOut(true) @@ -112,3 +114,28 @@ final class UpdateAnalyticsSettingsUseCaseTests: XCTestCase { SessionManager.removeTestingDatabase() } } + +final class TestAnalytics: Analytics { + var lastReceivedEventName: String? = nil + var lastReceivedWaitingTime: TimeInterval? = nil + + // MARK: - Protocol conformance + + func initialize() { + } + + func track(_ eventName: String, properties: [AnyHashable: Any]?, error: Error?) { + lastReceivedEventName = eventName + lastReceivedWaitingTime = properties?["waiting_time"] as? TimeInterval + } + + func refreshUserData() { + } + + func setUserHasOptedOut(_ optedOut: Bool) { + userHasOptedIn = !optedOut + } + + var userHasOptedIn: Bool = true + private(set) var analyticsProvider: AnalyticsProvider = MockAnalyticsProvider() +} From bdf07a1389d91b59d9051150629036f44ed160fe Mon Sep 17 00:00:00 2001 From: Jaclyn Chen Date: Wed, 24 Sep 2025 15:49:19 +0800 Subject: [PATCH 2/2] Track event for top performers waiting time. --- .../TopPerformers/TopPerformersDashboardViewModel.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/TopPerformers/TopPerformersDashboardViewModel.swift b/WooCommerce/Classes/ViewRelated/Dashboard/TopPerformers/TopPerformersDashboardViewModel.swift index 06cb12b8535..dff59e90671 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/TopPerformers/TopPerformersDashboardViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/TopPerformers/TopPerformersDashboardViewModel.swift @@ -208,7 +208,9 @@ private extension TopPerformersDashboardViewModel { analytics.track(event: .Dashboard.dashboardTopPerformersLoaded(timeRange: timeRange)) analytics.track(event: .DynamicDashboard.cardLoadingCompleted(type: .topPerformers)) } - waitingTracker?.end() + if let event = waitingTracker?.end() { + analytics.track(event: event) + } } .store(in: &subscriptions) }