diff --git a/Modules/Sources/PointOfSale/Models/PointOfSaleAggregateModel.swift b/Modules/Sources/PointOfSale/Models/PointOfSaleAggregateModel.swift index 7b2568455c7..ae68bda4fef 100644 --- a/Modules/Sources/PointOfSale/Models/PointOfSaleAggregateModel.swift +++ b/Modules/Sources/PointOfSale/Models/PointOfSaleAggregateModel.swift @@ -69,7 +69,9 @@ protocol PointOfSaleAggregateModelProtocol { private var cardReaderDisconnection: AnyCancellable? private let soundPlayer: PointOfSaleSoundPlayerProtocol - private let isLocalCatalogEligible: Bool + + /// Indicates whether the local catalog feature is enabled for this store + let isLocalCatalogEligible: Bool private var cancellables: Set = [] @@ -686,6 +688,13 @@ extension PointOfSaleAggregateModel { guard let catalogSyncCoordinator else { return } isSyncStale = await catalogSyncCoordinator.isSyncStale(for: siteID, maxDays: Constants.staleSyncThresholdDays) } + + /// Calculates the number of hours since the last catalog sync + /// - Returns: Hours since last sync, or nil if no sync date is available + func hoursSinceLastSync() async -> Int? { + guard let catalogSyncCoordinator else { return nil } + return await catalogSyncCoordinator.hoursSinceLastSync(for: siteID) + } } // MARK: - Constants diff --git a/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/UI States/PointOfSaleLoadingView.swift b/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/UI States/PointOfSaleLoadingView.swift index 6cf7e343a96..a60bc5ee1c0 100644 --- a/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/UI States/PointOfSaleLoadingView.swift +++ b/Modules/Sources/PointOfSale/Presentation/CardReaderConnection/UI States/PointOfSaleLoadingView.swift @@ -1,6 +1,9 @@ import SwiftUI +import struct WooFoundationCore.WooAnalyticsEvent struct PointOfSaleLoadingView: View { + @Environment(\.posAnalytics) private var analytics + private let isCatalogSyncing: Bool private let onExit: (() -> Void)? @@ -24,6 +27,7 @@ struct PointOfSaleLoadingView: View { Spacer() VStack(spacing: POSSpacing.medium) { Button { + analytics.track(event: WooAnalyticsEvent.LocalCatalog.downloadingScreenExitPosTapped()) onExit?() } label: { Text(Localization.exitButtonTitle) @@ -44,6 +48,11 @@ struct PointOfSaleLoadingView: View { Spacer() } .background(Color.posSurface) + .task { + if isCatalogSyncing { + analytics.track(event: WooAnalyticsEvent.LocalCatalog.downloadingScreenShown()) + } + } } } diff --git a/Modules/Sources/PointOfSale/Presentation/ItemListView.swift b/Modules/Sources/PointOfSale/Presentation/ItemListView.swift index db206589d6b..c909302df59 100644 --- a/Modules/Sources/PointOfSale/Presentation/ItemListView.swift +++ b/Modules/Sources/PointOfSale/Presentation/ItemListView.swift @@ -1,6 +1,7 @@ import SwiftUI import enum Yosemite.POSItem import protocol Yosemite.POSOrderableItem +import struct WooFoundationCore.WooAnalyticsEvent struct ItemListView: View { @Environment(\.posAnalytics) private var analytics @@ -195,6 +196,7 @@ struct ItemListView: View { title: Localization.staleSyncWarningTitle, icon: Image(systemName: "info.circle"), onDismiss: { + analytics.track(event: WooAnalyticsEvent.LocalCatalog.staleWarningDismissed()) withAnimation { posModel.dismissStaleSyncWarning() } @@ -202,6 +204,12 @@ struct ItemListView: View { Text(Localization.staleSyncWarningDescription(days: posModel.staleSyncThresholdDays)) .font(POSFontStyle.posBodyMediumRegular()) }) + .task { + // Track stale warning shown with hours since last sync + if let hours = await posModel.hoursSinceLastSync() { + analytics.track(event: WooAnalyticsEvent.LocalCatalog.staleWarningShown(hoursSinceLastSync: hours)) + } + } } private func actionHandler(_ itemListType: ItemListType) -> POSItemActionHandler { diff --git a/Modules/Sources/PointOfSale/Presentation/PointOfSaleDashboardView.swift b/Modules/Sources/PointOfSale/Presentation/PointOfSaleDashboardView.swift index 99bb927c1be..6e759542208 100644 --- a/Modules/Sources/PointOfSale/Presentation/PointOfSaleDashboardView.swift +++ b/Modules/Sources/PointOfSale/Presentation/PointOfSaleDashboardView.swift @@ -243,7 +243,8 @@ private extension PointOfSaleDashboardView { func trackElapsedTimeForInitialLoadingState() { if let waitingTimeTracker { - let event = waitingTimeTracker.end(using: .milliseconds) + let syncStrategy = posModel.isLocalCatalogEligible ? "local_catalog" : "remote" + let event = waitingTimeTracker.end(using: .milliseconds, additionalProperties: ["sync_strategy": syncStrategy]) analytics.track(event: event) self.waitingTimeTracker = nil } diff --git a/Modules/Sources/PointOfSale/Presentation/Reusable Views/POSListErrorView.swift b/Modules/Sources/PointOfSale/Presentation/Reusable Views/POSListErrorView.swift index 6c0d4a2f99f..ef21a89df7b 100644 --- a/Modules/Sources/PointOfSale/Presentation/Reusable Views/POSListErrorView.swift +++ b/Modules/Sources/PointOfSale/Presentation/Reusable Views/POSListErrorView.swift @@ -1,9 +1,13 @@ import SwiftUI import WooFoundation +import struct WooFoundationCore.WooAnalyticsEvent /// A view that displays an error message with a retry CTA when the list of POS items fails to load. struct POSListErrorView: View { @Environment(\.floatingControlAreaSize) private var floatingControlAreaSize: CGSize + @Environment(\.posAnalytics) private var analytics + + private let error: PointOfSaleErrorState private let viewModel: POSListErrorViewModel private let onAction: (() -> Void)? private let onExit: (() -> Void)? @@ -13,6 +17,7 @@ struct POSListErrorView: View { @Environment(\.keyboardObserver) private var keyboard init(error: PointOfSaleErrorState, onAction: (() -> Void)? = nil, onExit: (() -> Void)? = nil) { + self.error = error self.viewModel = POSListErrorViewModel(error: error) self.onAction = onAction self.onExit = onExit @@ -52,6 +57,10 @@ struct POSListErrorView: View { if let onAction { Spacer().frame(height: PointOfSaleEmptyErrorStateViewLayout.textAndButtonSpacing) Button(action: { + // Track retry tapped for splash screen errors (initial catalog sync) + if error.errorType == .initialCatalogSyncError { + analytics.track(event: WooAnalyticsEvent.LocalCatalog.splashScreenRetryTapped()) + } onAction() }, label: { Text(viewModel.buttonText) @@ -79,6 +88,12 @@ struct POSListErrorView: View { .measureWidth { width in viewWidth = width } + .onAppear { + // Track error shown for splash screen errors (initial catalog sync) + if error.errorType == .initialCatalogSyncError { + analytics.track(event: WooAnalyticsEvent.LocalCatalog.splashScreenErrorShown()) + } + } } } diff --git a/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift b/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift index fcfc8699c67..828ab2050b2 100644 --- a/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift +++ b/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift @@ -642,6 +642,11 @@ final class POSPreviewCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol return false } + func hoursSinceLastSync(for siteID: Int64) async -> Int? { + // Preview implementation - return 48 hours for testing stale warning + return 48 + } + func stopOngoingSyncs(for siteID: Int64) async { // Preview implementation - no-op } diff --git a/Modules/Sources/WooFoundation/Utilities/WaitingTimeTracker.swift b/Modules/Sources/WooFoundation/Utilities/WaitingTimeTracker.swift index aba1aae82f6..180c3cc2265 100644 --- a/Modules/Sources/WooFoundation/Utilities/WaitingTimeTracker.swift +++ b/Modules/Sources/WooFoundation/Utilities/WaitingTimeTracker.swift @@ -32,11 +32,12 @@ public class WaitingTimeTracker { /// and returning an analytics event for tracking. /// /// - Parameter trackingUnit: Defines whether the elapsed time should be tracked in `.seconds` or `.milliseconds` (default is `.seconds`). + /// - Parameter additionalProperties: Optional additional properties to include in the analytics event. /// - Returns: The analytics event to be tracked. /// - public func end(using trackingUnit: TrackingUnit = .seconds) -> WooAnalyticsEvent { + public func end(using trackingUnit: TrackingUnit = .seconds, additionalProperties: [String: String] = [:]) -> WooAnalyticsEvent { let elapsedTime = calculateElapsedTime(in: trackingUnit) - return .WaitingTime.waitingFinished(scenario: trackScenario, elapsedTime: elapsedTime) + return .WaitingTime.waitingFinished(scenario: trackScenario, elapsedTime: elapsedTime, additionalProperties: additionalProperties) } /// Calculates elapsed time in the specified tracking unit. @@ -66,21 +67,40 @@ public extension WooAnalyticsEvent { static let millisecondsTimeElapsedInSplashScreen = "milliseconds_time_elapsed_in_splash_screen" } - static func waitingFinished(scenario: Scenario, elapsedTime: TimeInterval) -> WooAnalyticsEvent { + static func waitingFinished(scenario: Scenario, + elapsedTime: TimeInterval, + additionalProperties: [String: String] = [:]) -> WooAnalyticsEvent { + // Convert additional properties to WooAnalyticsEventPropertyType + let typedAdditionalProperties: [String: WooAnalyticsEventPropertyType] = + additionalProperties.mapValues { $0 as WooAnalyticsEventPropertyType } + + let statName: WooAnalyticsStat + var baseProperties: [String: WooAnalyticsEventPropertyType] + switch scenario { case .orderDetails: - return WooAnalyticsEvent(statName: .orderDetailWaitingTimeLoaded, properties: [Keys.waitingTime: elapsedTime]) + statName = .orderDetailWaitingTimeLoaded + baseProperties = [Keys.waitingTime: elapsedTime] case .dashboardTopPerformers: - return WooAnalyticsEvent(statName: .dashboardTopPerformersWaitingTimeLoaded, properties: [Keys.waitingTime: elapsedTime]) + statName = .dashboardTopPerformersWaitingTimeLoaded + baseProperties = [Keys.waitingTime: elapsedTime] case .dashboardMainStats: - return WooAnalyticsEvent(statName: .dashboardMainStatsWaitingTimeLoaded, properties: [Keys.waitingTime: elapsedTime]) + statName = .dashboardMainStatsWaitingTimeLoaded + baseProperties = [Keys.waitingTime: elapsedTime] case .analyticsHub: - return WooAnalyticsEvent(statName: .analyticsHubWaitingTimeLoaded, properties: [Keys.waitingTime: elapsedTime]) + statName = .analyticsHubWaitingTimeLoaded + baseProperties = [Keys.waitingTime: elapsedTime] case .appStartup: - return WooAnalyticsEvent(statName: .applicationOpenedWaitingTimeLoaded, properties: [Keys.waitingTime: elapsedTime]) + statName = .applicationOpenedWaitingTimeLoaded + baseProperties = [Keys.waitingTime: elapsedTime] case .pointOfSaleLoaded: - return WooAnalyticsEvent(statName: .pointOfSaleLoaded, properties: [Keys.millisecondsTimeElapsedInSplashScreen: elapsedTime]) + statName = .pointOfSaleLoaded + baseProperties = [Keys.millisecondsTimeElapsedInSplashScreen: elapsedTime] } + + return WooAnalyticsEvent( + statName: statName, + properties: baseProperties.merging(typedAdditionalProperties) { $1 }) } } } diff --git a/Modules/Sources/WooFoundationCore/Analytics/WooAnalyticsEvent.swift b/Modules/Sources/WooFoundationCore/Analytics/WooAnalyticsEvent.swift index d9cd0141e8a..ebcf55d5760 100644 --- a/Modules/Sources/WooFoundationCore/Analytics/WooAnalyticsEvent.swift +++ b/Modules/Sources/WooFoundationCore/Analytics/WooAnalyticsEvent.swift @@ -48,3 +48,100 @@ public struct WooAnalyticsEvent { self.error = error } } + +// MARK: - Local Catalog Analytics Events +extension WooAnalyticsEvent { + /// Analytics events for Local Catalog feature + public enum LocalCatalog { + /// Event property Key. + private enum Key { + static let hoursSinceLastSync = "hours_since_last_sync" + static let syncType = "sync_type" + static let connectionType = "connection_type" + static let productsSynced = "products_synced" + static let variationsSynced = "variations_synced" + static let totalProducts = "total_products" + static let totalVariations = "total_variations" + static let syncDurationMs = "sync_duration_ms" + static let errorType = "error_type" + static let reason = "reason" + } + + // MARK: - Initial Launch & Loading Screen Events + + public static func downloadingScreenShown() -> WooAnalyticsEvent { + WooAnalyticsEvent(statName: .pointOfSaleLocalCatalogDownloadingScreenShown, properties: [:]) + } + + public static func downloadingScreenExitPosTapped() -> WooAnalyticsEvent { + WooAnalyticsEvent(statName: .pointOfSaleLocalCatalogDownloadingScreenExitPosTapped, properties: [:]) + } + + public static func splashScreenErrorShown() -> WooAnalyticsEvent { + WooAnalyticsEvent(statName: .pointOfSaleSplashScreenErrorShown, properties: [:]) + } + + public static func splashScreenRetryTapped() -> WooAnalyticsEvent { + WooAnalyticsEvent(statName: .pointOfSaleSplashScreenRetryTapped, properties: [:]) + } + + // MARK: - Stale Catalog Warning Events + + public static func staleWarningShown(hoursSinceLastSync: Int) -> WooAnalyticsEvent { + WooAnalyticsEvent(statName: .pointOfSaleLocalCatalogStaleWarningShown, + properties: [Key.hoursSinceLastSync: "\(hoursSinceLastSync)"]) + } + + public static func staleWarningDismissed() -> WooAnalyticsEvent { + WooAnalyticsEvent(statName: .pointOfSaleLocalCatalogStaleWarningDismissed, properties: [:]) + } + + // MARK: - Core Sync Events + + public static func syncStarted(syncType: String, connectionType: String) -> WooAnalyticsEvent { + WooAnalyticsEvent(statName: .pointOfSaleLocalCatalogSyncStarted, + properties: [ + Key.syncType: syncType, + Key.connectionType: connectionType + ]) + } + + public static func syncCompleted( + syncType: String, + productsSynced: Int, + variationsSynced: Int, + totalProducts: Int, + totalVariations: Int, + syncDurationMs: Int + ) -> WooAnalyticsEvent { + WooAnalyticsEvent(statName: .pointOfSaleLocalCatalogSyncCompleted, + properties: [ + Key.syncType: syncType, + Key.productsSynced: "\(productsSynced)", + Key.variationsSynced: "\(variationsSynced)", + Key.totalProducts: "\(totalProducts)", + Key.totalVariations: "\(totalVariations)", + Key.syncDurationMs: "\(syncDurationMs)" + ]) + } + + public static func syncFailed( + syncType: String, + error: Error, + errorClassifier: (Error) -> String + ) -> WooAnalyticsEvent { + let errorType = errorClassifier(error) + return WooAnalyticsEvent(statName: .pointOfSaleLocalCatalogSyncFailed, + properties: [ + Key.syncType: syncType, + Key.errorType: errorType + ], + error: error) + } + + public static func syncSkipped(reason: String) -> WooAnalyticsEvent { + WooAnalyticsEvent(statName: .pointOfSaleLocalCatalogSyncSkipped, + properties: [Key.reason: reason]) + } + } +} diff --git a/Modules/Sources/WooFoundationCore/Analytics/WooAnalyticsStat.swift b/Modules/Sources/WooFoundationCore/Analytics/WooAnalyticsStat.swift index e1f99108ffb..2b44a7c5230 100644 --- a/Modules/Sources/WooFoundationCore/Analytics/WooAnalyticsStat.swift +++ b/Modules/Sources/WooFoundationCore/Analytics/WooAnalyticsStat.swift @@ -1305,6 +1305,16 @@ public enum WooAnalyticsStat: String { case pointOfSaleOrdersListSearchResultsFetched = "orders_list_search_results_fetched" case pointOfSaleOrderDetailsLoaded = "order_details_loaded" case pointOfSaleOrderDetailsEmailReceiptTapped = "order_details_email_receipt_tapped" + case pointOfSaleLocalCatalogDownloadingScreenShown = "local_catalog_downloading_screen_shown" + case pointOfSaleLocalCatalogDownloadingScreenExitPosTapped = "local_catalog_downloading_screen_exit_pos_tapped" + case pointOfSaleSplashScreenErrorShown = "splash_screen_error_shown" + case pointOfSaleSplashScreenRetryTapped = "splash_screen_retry_tapped" + case pointOfSaleLocalCatalogStaleWarningShown = "local_catalog_stale_warning_shown" + case pointOfSaleLocalCatalogStaleWarningDismissed = "local_catalog_stale_warning_dismissed" + case pointOfSaleLocalCatalogSyncStarted = "local_catalog_sync_started" + case pointOfSaleLocalCatalogSyncCompleted = "local_catalog_sync_completed" + case pointOfSaleLocalCatalogSyncFailed = "local_catalog_sync_failed" + case pointOfSaleLocalCatalogSyncSkipped = "local_catalog_sync_skipped" // MARK: Custom Fields events case productDetailCustomFieldsTapped = "product_detail_custom_fields_tapped" diff --git a/Modules/Sources/Yosemite/Protocols/POSLocalCatalogEligibilityServiceProtocol.swift b/Modules/Sources/Yosemite/Protocols/POSLocalCatalogEligibilityServiceProtocol.swift index 9faef02d4d2..6a1d43cc6ec 100644 --- a/Modules/Sources/Yosemite/Protocols/POSLocalCatalogEligibilityServiceProtocol.swift +++ b/Modules/Sources/Yosemite/Protocols/POSLocalCatalogEligibilityServiceProtocol.swift @@ -17,6 +17,22 @@ public enum POSLocalCatalogIneligibleReason: Equatable { case unsupportedWooCommerceVersion(minimumVersion: String) case catalogSizeTooLarge(totalCount: Int, limit: Int) case catalogSizeCheckFailed(underlyingError: String) + + /// Analytics skip reason string representation + public var skipReason: String { + switch self { + case .posTabNotEligible: + return "pos_inactive" + case .featureFlagDisabled: + return "feature_flag_disabled" + case .unsupportedWooCommerceVersion: + return "unsupported_woocommerce_version" + case .catalogSizeTooLarge: + return "catalog_too_large" + case .catalogSizeCheckFailed: + return "catalog_size_check_failed" + } + } } /// Service that provides eligibility information for local catalog feature diff --git a/Modules/Sources/Yosemite/Tools/POS/POSCatalogIncrementalSyncService.swift b/Modules/Sources/Yosemite/Tools/POS/POSCatalogIncrementalSyncService.swift index 86d2fa259b1..4148d9e0e05 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSCatalogIncrementalSyncService.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSCatalogIncrementalSyncService.swift @@ -13,7 +13,8 @@ public protocol POSCatalogIncrementalSyncServiceProtocol { /// - Parameters: /// - siteID: The site ID to sync catalog for. /// - lastFullSyncDate: The date of the last full sync to use if no incremental sync date exists. - func startIncrementalSync(for siteID: Int64, lastFullSyncDate: Date, lastIncrementalSyncDate: Date?) async throws + /// - Returns: The synced catalog containing updated products and variations + func startIncrementalSync(for siteID: Int64, lastFullSyncDate: Date, lastIncrementalSyncDate: Date?) async throws -> POSCatalog } // TODO - remove the periphery ignore comment when the service is integrated with POS. @@ -53,7 +54,7 @@ public final class POSCatalogIncrementalSyncService: POSCatalogIncrementalSyncSe // MARK: - Protocol Conformance - public func startIncrementalSync(for siteID: Int64, lastFullSyncDate: Date, lastIncrementalSyncDate: Date?) async throws { + public func startIncrementalSync(for siteID: Int64, lastFullSyncDate: Date, lastIncrementalSyncDate: Date?) async throws -> POSCatalog { let modifiedAfter = latestSyncDate(fullSyncDate: lastFullSyncDate, incrementalSyncDate: lastIncrementalSyncDate) DDLogInfo("🔄 Starting incremental catalog sync for site ID: \(siteID), modifiedAfter: \(modifiedAfter)") @@ -65,6 +66,7 @@ public final class POSCatalogIncrementalSyncService: POSCatalogIncrementalSyncSe try await persistenceService.persistIncrementalCatalogData(catalog, siteID: siteID) DDLogInfo("✅ Persisted \(catalog.products.count) updated products and \(catalog.variations.count) updated variations to database for siteID \(siteID)") + return catalog } catch { DDLogError("❌ Failed to sync and persist catalog incrementally: \(error)") throw error diff --git a/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift b/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift index e0b8ec32281..3214ccc9ad0 100644 --- a/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift +++ b/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift @@ -3,6 +3,10 @@ import Foundation import Storage import GRDB import Alamofire +import protocol WooFoundation.Analytics +import protocol WooFoundation.ConnectivityObserver +import enum WooFoundation.ConnectionType +import struct WooFoundationCore.WooAnalyticsEvent public protocol POSCatalogSyncCoordinatorProtocol { /// Performs a full catalog sync if applicable for the specified site @@ -43,6 +47,11 @@ public protocol POSCatalogSyncCoordinatorProtocol { /// - Returns: True if the last sync is older than the specified days or if there has been no sync func isSyncStale(for siteID: Int64, maxDays: Int) async -> Bool + /// Returns the number of hours since the last catalog sync + /// - Parameter siteID: The site ID to check + /// - Returns: Hours since last sync, or nil if no sync date is available + func hoursSinceLastSync(for siteID: Int64) async -> Int? + /// Stops all ongoing sync tasks for the specified site /// - Parameter siteID: The site ID to stop syncs for func stopOngoingSyncs(for siteID: Int64) async @@ -83,21 +92,29 @@ public enum POSCatalogSyncError: Error, Equatable { case shouldNotSync } +/// Type of catalog sync operation for analytics tracking +public enum POSCatalogSyncType: String { + case full + case incremental +} + public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol { private let fullSyncService: POSCatalogFullSyncServiceProtocol private let incrementalSyncService: POSCatalogIncrementalSyncServiceProtocol private let grdbManager: GRDBManagerProtocol private let catalogEligibilityChecker: POSLocalCatalogEligibilityServiceProtocol private let siteSettings: SiteSpecificAppSettingsStoreMethodsProtocol + private let analytics: Analytics? + private let connectivityObserver: ConnectivityObserver? /// Tracks ongoing incremental syncs by site ID to prevent duplicates private var ongoingIncrementalSyncs: Set = [] /// Tracks ongoing full sync tasks by site ID for cancellation - private var ongoingFullSyncTasks: [Int64: Task] = [:] + private var ongoingFullSyncTasks: [Int64: Task] = [:] /// Tracks ongoing incremental sync tasks by site ID for cancellation - private var ongoingIncrementalSyncTasks: [Int64: Task] = [:] + private var ongoingIncrementalSyncTasks: [Int64: Task] = [:] /// Observable model for full sync state updates public nonisolated let fullSyncStateModel: POSCatalogSyncStateModel = .init() @@ -106,12 +123,16 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol { incrementalSyncService: POSCatalogIncrementalSyncServiceProtocol, grdbManager: GRDBManagerProtocol, catalogEligibilityChecker: POSLocalCatalogEligibilityServiceProtocol, - siteSettings: SiteSpecificAppSettingsStoreMethodsProtocol? = nil) { + siteSettings: SiteSpecificAppSettingsStoreMethodsProtocol? = nil, + analytics: Analytics? = nil, + connectivityObserver: ConnectivityObserver? = nil) { self.fullSyncService = fullSyncService self.incrementalSyncService = incrementalSyncService self.grdbManager = grdbManager self.catalogEligibilityChecker = catalogEligibilityChecker self.siteSettings = siteSettings ?? SiteSpecificAppSettingsStoreMethods(fileStorage: PListFileStorage()) + self.analytics = analytics + self.connectivityObserver = connectivityObserver } public func performFullSyncIfApplicable(for siteID: Int64, maxAge: TimeInterval, regenerateCatalog: Bool) async throws { @@ -120,6 +141,8 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol { } guard try await shouldPerformFullSync(for: siteID, maxAge: maxAge) else { + let reason = await getSyncSkipReason(for: siteID, maxAge: maxAge) + trackAnalytics(WooAnalyticsEvent.LocalCatalog.syncSkipped(reason: reason)) throw POSCatalogSyncError.shouldNotSync } @@ -132,14 +155,20 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol { emitSyncState(isFirstSync ? .initialSyncStarted(siteID: siteID) : .syncStarted(siteID: siteID)) + // Track sync started analytics + let connectionType = getConnectionType() + trackAnalytics(WooAnalyticsEvent.LocalCatalog.syncStarted(syncType: POSCatalogSyncType.full.rawValue, connectionType: connectionType)) + let allowCellular = isFirstSync || siteSettings.getPOSLocalCatalogCellularDataAllowed(siteID: siteID) DDLogInfo("🔄 POSCatalogSyncCoordinator starting full sync for site \(siteID)") + let syncStartTime = Date() + // Create a task to perform the sync - let syncTask = Task { - _ = try await fullSyncService.startFullSync(for: siteID, - regenerateCatalog: regenerateCatalog, - allowCellular: allowCellular) + let syncTask = Task { + try await fullSyncService.startFullSync(for: siteID, + regenerateCatalog: regenerateCatalog, + allowCellular: allowCellular) } // Store the task for potential cancellation @@ -150,14 +179,32 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol { } do { - try await syncTask.value + let syncedCatalog = try await syncTask.value emitSyncState(.syncCompleted(siteID: siteID)) + + // Track sync completed analytics + let syncDurationMs = Int(Date().timeIntervalSince(syncStartTime) * 1000) + let (totalProducts, totalVariations) = await getStorageCounts(for: siteID) + trackAnalytics(WooAnalyticsEvent.LocalCatalog.syncCompleted( + syncType: POSCatalogSyncType.full.rawValue, + productsSynced: syncedCatalog.products.count, + variationsSynced: syncedCatalog.variations.count, + totalProducts: totalProducts, + totalVariations: totalVariations, + syncDurationMs: syncDurationMs + )) } catch AFError.explicitlyCancelled, is CancellationError { if isFirstSync { emitSyncState(.initialSyncFailed(siteID: siteID, error: POSCatalogSyncError.requestCancelled)) } else { emitSyncState(.syncFailed(siteID: siteID, error: POSCatalogSyncError.requestCancelled)) } + // Track sync failed analytics + trackAnalytics(WooAnalyticsEvent.LocalCatalog.syncFailed( + syncType: POSCatalogSyncType.full.rawValue, + error: POSCatalogSyncError.requestCancelled, + errorClassifier: POSCatalogSyncErrorClassifier.classify + )) throw POSCatalogSyncError.requestCancelled } catch { DDLogError("⛔️ POSCatalogSyncCoordinator failed to complete sync for site \(siteID): \(error)") @@ -166,6 +213,12 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol { } else { emitSyncState(.syncFailed(siteID: siteID, error: error)) } + // Track sync failed analytics + trackAnalytics(WooAnalyticsEvent.LocalCatalog.syncFailed( + syncType: POSCatalogSyncType.full.rawValue, + error: error, + errorClassifier: POSCatalogSyncErrorClassifier.classify + )) throw error } @@ -254,6 +307,8 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol { } guard try await shouldPerformIncrementalSync(for: siteID, maxAge: maxAge) else { + let reason = await getIncrementalSyncSkipReason(for: siteID, maxAge: maxAge) + trackAnalytics(WooAnalyticsEvent.LocalCatalog.syncSkipped(reason: reason)) return } @@ -279,8 +334,14 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol { DDLogInfo("🔄 POSCatalogSyncCoordinator starting incremental sync for site \(siteID)") + // Track sync started analytics + let connectionType = getConnectionType() + trackAnalytics(WooAnalyticsEvent.LocalCatalog.syncStarted(syncType: POSCatalogSyncType.incremental.rawValue, connectionType: connectionType)) + + let syncStartTime = Date() + // Create a task to perform the sync - let syncTask = Task { + let syncTask = Task { try await incrementalSyncService.startIncrementalSync(for: siteID, lastFullSyncDate: lastFullSyncDate, lastIncrementalSyncDate: await lastIncrementalSyncDate(for: siteID)) @@ -294,13 +355,39 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol { } do { - try await syncTask.value + let syncedCatalog = try await syncTask.value + DDLogInfo("✅ POSCatalogSyncCoordinator completed incremental sync for site \(siteID)") + + // Track sync completed analytics + let syncDurationMs = Int(Date().timeIntervalSince(syncStartTime) * 1000) + let (totalProducts, totalVariations) = await getStorageCounts(for: siteID) + trackAnalytics(WooAnalyticsEvent.LocalCatalog.syncCompleted( + syncType: POSCatalogSyncType.incremental.rawValue, + productsSynced: syncedCatalog.products.count, + variationsSynced: syncedCatalog.variations.count, + totalProducts: totalProducts, + totalVariations: totalVariations, + syncDurationMs: syncDurationMs + )) } catch AFError.explicitlyCancelled, is CancellationError { + // Track sync failed analytics + trackAnalytics(WooAnalyticsEvent.LocalCatalog.syncFailed( + syncType: POSCatalogSyncType.incremental.rawValue, + error: POSCatalogSyncError.requestCancelled, + errorClassifier: POSCatalogSyncErrorClassifier.classify + )) throw POSCatalogSyncError.requestCancelled + } catch { + DDLogError("⛔️ POSCatalogSyncCoordinator failed to complete incremental sync for site \(siteID): \(error)") + // Track sync failed analytics + trackAnalytics(WooAnalyticsEvent.LocalCatalog.syncFailed( + syncType: POSCatalogSyncType.incremental.rawValue, + error: error, + errorClassifier: POSCatalogSyncErrorClassifier.classify + )) + throw error } - DDLogInfo("✅ POSCatalogSyncCoordinator completed incremental sync for site \(siteID)") - // Record first sync date if this was the first successful sync recordFirstSyncIfNeeded(for: siteID) } @@ -396,6 +483,14 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol { return lastFullSync < thresholdDate } + public func hoursSinceLastSync(for siteID: Int64) async -> Int? { + guard let lastSyncDate = await lastFullSyncDate(for: siteID) else { + return nil + } + let timeInterval = Date().timeIntervalSince(lastSyncDate) + return Int(timeInterval / 3600) // Convert seconds to hours + } + public func stopOngoingSyncs(for siteID: Int64) async { DDLogInfo("🛑 POSCatalogSyncCoordinator: Stopping ongoing syncs for site \(siteID)") @@ -446,6 +541,93 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol { // Record first sync date if needed recordFirstSyncIfNeeded(for: siteID) } + + // MARK: - Analytics Helpers + + private nonisolated func trackAnalytics(_ event: WooAnalyticsEvent) { + analytics?.track(event.statName.rawValue, properties: event.properties, error: event.error) + } + + private nonisolated func getConnectionType() -> String { + guard let observer = connectivityObserver else { return "unknown" } + switch observer.currentStatus { + case .reachable(let connectionType): + switch connectionType { + case .ethernetOrWiFi: + return "wifi" + case .cellular: + return "cellular" + case .other: + return "unknown" + } + case .notReachable, .unknown: + return "unknown" + } + } + + private func getStorageCounts(for siteID: Int64) async -> (products: Int, variations: Int) { + do { + return try await grdbManager.databaseConnection.read { db in + let productCount = try PersistedProduct.filter { $0.siteID == siteID }.fetchCount(db) + let variationCount = try PersistedProductVariation.filter { $0.siteID == siteID }.fetchCount(db) + return (productCount, variationCount) + } + } catch { + DDLogError("⛔️ POSCatalogSyncCoordinator: Failed to get storage counts: \(error)") + return (products: 0, variations: 0) + } + } + + private func getSyncSkipReason(for siteID: Int64, maxAge: TimeInterval) async -> String { + // Check eligibility + do { + let eligibility = try await catalogEligibilityChecker.catalogEligibility(for: siteID) + if case .ineligible(let reason) = eligibility { + return reason.skipReason + } + } catch { + return "eligibility_check_failed" + } + + // Check if sync is needed based on age + guard let lastSyncDate = await lastFullSyncDate(for: siteID) else { + return "no_previous_sync" // This shouldn't happen if shouldPerformFullSync returned false + } + + let age = Date().timeIntervalSince(lastSyncDate) + if age < maxAge { + return "catalog_not_stale" + } + + return "unknown_reason" + } + + private func getIncrementalSyncSkipReason(for siteID: Int64, maxAge: TimeInterval) async -> String { + // Check eligibility first + do { + let eligibility = try await catalogEligibilityChecker.catalogEligibility(for: siteID) + if case .ineligible(let reason) = eligibility { + return reason.skipReason + } + } catch { + return "eligibility_check_failed" + } + + // Check if full sync exists + guard await lastFullSyncDate(for: siteID) != nil else { + return "no_full_sync" + } + + // Check if incremental sync is needed based on age + if maxAge > 0, let lastIncrementalSyncDate = await lastIncrementalSyncDate(for: siteID) { + let age = Date().timeIntervalSince(lastIncrementalSyncDate) + if age <= maxAge { + return "catalog_not_stale" + } + } + + return "unknown_reason" + } } // MARK: - Syncing State diff --git a/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncErrorClassifier.swift b/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncErrorClassifier.swift new file mode 100644 index 00000000000..851eb77111f --- /dev/null +++ b/Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncErrorClassifier.swift @@ -0,0 +1,154 @@ +import Foundation +import GRDB +import enum NetworkingCore.NetworkError +import enum NetworkingCore.DotcomError +import enum Alamofire.AFError + +/// Classifies errors from catalog sync operations for analytics tracking. +/// This classifier has access to concrete error types from Storage and Networking layers. +enum POSCatalogSyncErrorClassifier { + /// Classifies a sync error into an analytics-friendly error type string + static func classify(_ error: Error) -> String { + // Check for GRDB DatabaseError (has actual type information) + if let dbError = error as? DatabaseError { + return classifyDatabaseError(dbError) + } + + // Check for Alamofire errors (may wrap other errors) + if let afError = error as? AFError { + return classifyAFError(afError) + } + + // Check for cancellation errors + if error is CancellationError { + return "request_cancelled" + } + + // Check for network errors + if let networkError = error as? NetworkError { + return classifyNetworkError(networkError) + } + + // Check for URLError + if let urlError = error as? URLError { + return classifyURLError(urlError) + } + + // Check for authentication errors + if let dotcomError = error as? DotcomError { + return classifyDotcomError(dotcomError) + } + + // Check for parsing/decoding errors + if error is DecodingError { + return "catalog_integrity" + } + + // Fallback: Check NSError domain/code for test mocks or wrapped errors + let nsError = error as NSError + + // Check HTTP status codes + if nsError.code == 401 || nsError.code == 403 { + return "authentication_error" + } + + // Check domain for network-related errors (for mocks) + if nsError.domain.contains("Network") || nsError.domain.contains("URLError") { + return "network_error" + } + + // Check domain for database errors (for mocks) + if nsError.domain.contains("GRDB") || nsError.domain.contains("Database") { + if nsError.code == 13 { + return "insufficient_free_space" + } + return "database_error" + } + + // Default to unexpected error + DDLogWarn("⚠️ Unclassified catalog sync error: domain=\(nsError.domain), code=\(nsError.code), \(error)") + return "unexpected_error" + } + + // MARK: - Private Helpers + + private static func classifyAFError(_ error: AFError) -> String { + // Check for explicit cancellation + if error.isExplicitlyCancelledError { + return "request_cancelled" + } + + // Check for session task errors (wraps URLError and other Foundation errors) + if case .sessionTaskFailed(let underlyingError) = error { + // Recursively classify the underlying error + return classify(underlyingError) + } + + // Check for response validation errors (HTTP status codes) + if case .responseValidationFailed(let reason) = error { + switch reason { + case .unacceptableStatusCode(let statusCode): + if statusCode == 401 || statusCode == 403 { + return "authentication_error" + } + return "network_error" + default: + return "network_error" + } + } + + // Check for invalid URL + if case .invalidURL = error { + return "network_error" + } + + // Other Alamofire errors are generally network-related + return "network_error" + } + + private static func classifyNetworkError(_ error: NetworkError) -> String { + switch error { + case .timeout: + return "network_timeout" + case .unacceptableStatusCode(let statusCode, _): + if statusCode == 401 || statusCode == 403 { + return "authentication_error" + } + return "network_error" + default: + return "network_error" + } + } + + private static func classifyDatabaseError(_ error: DatabaseError) -> String { + switch error.resultCode { + case .SQLITE_FULL: + return "insufficient_free_space" + case .SQLITE_CORRUPT, .SQLITE_NOTADB: + return "database_corruption" + case .SQLITE_CONSTRAINT: + return "database_constraint_violation" + default: + return "database_error" + } + } + + private static func classifyURLError(_ error: URLError) -> String { + switch error.code { + case .timedOut: + return "network_timeout" + default: + return "network_error" + } + } + + private static func classifyDotcomError(_ error: DotcomError) -> String { + // DotcomError has HTTP status codes + switch error { + case .unauthorized: + return "authentication_error" + default: + return "network_error" + } + } +} diff --git a/Modules/Tests/PointOfSaleTests/Mocks/MockPOSCatalogSyncCoordinator.swift b/Modules/Tests/PointOfSaleTests/Mocks/MockPOSCatalogSyncCoordinator.swift index cd7ba3e58af..ec09a005f03 100644 --- a/Modules/Tests/PointOfSaleTests/Mocks/MockPOSCatalogSyncCoordinator.swift +++ b/Modules/Tests/PointOfSaleTests/Mocks/MockPOSCatalogSyncCoordinator.swift @@ -75,6 +75,12 @@ final class MockPOSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol { return isSyncStaleResult } + var hoursSinceLastSyncResult: Int? = nil + + func hoursSinceLastSync(for siteID: Int64) async -> Int? { + return hoursSinceLastSyncResult + } + func stopOngoingSyncs(for siteID: Int64) async {} var processBackgroundDownloadResult: Result = .success(()) diff --git a/Modules/Tests/YosemiteTests/Mocks/MockAnalytics.swift b/Modules/Tests/YosemiteTests/Mocks/MockAnalytics.swift new file mode 100644 index 00000000000..30497ac350c --- /dev/null +++ b/Modules/Tests/YosemiteTests/Mocks/MockAnalytics.swift @@ -0,0 +1,36 @@ +import Foundation +import WooFoundation + +/// Simple mock for Analytics protocol used in Yosemite tests +final class MockAnalytics: Analytics { + struct TrackedEvent { + let eventName: String + let properties: [AnyHashable: Any]? + let error: Error? + } + + var trackedEvents: [TrackedEvent] = [] + var userHasOptedIn: Bool = true + let analyticsProvider: AnalyticsProvider = MockAnalyticsProvider() + + func initialize() {} + + func track(_ eventName: String, properties: [AnyHashable: Any]?, error: Error?) { + trackedEvents.append(TrackedEvent(eventName: eventName, properties: properties, error: error)) + } + + func refreshUserData() {} + + func setUserHasOptedOut(_ optedOut: Bool) { + userHasOptedIn = !optedOut + } +} + +/// Minimal mock for AnalyticsProvider +final class MockAnalyticsProvider: AnalyticsProvider { + func refreshUserData() {} + func track(_ eventName: String) {} + func track(_ eventName: String, withProperties properties: [AnyHashable: Any]?) {} + func clearEvents() {} + func clearUsers() {} +} diff --git a/Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogIncrementalSyncService.swift b/Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogIncrementalSyncService.swift index 131bcb8d2bf..676fc59d12f 100644 --- a/Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogIncrementalSyncService.swift +++ b/Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogIncrementalSyncService.swift @@ -2,7 +2,7 @@ import Foundation @testable import Yosemite final class MockPOSCatalogIncrementalSyncService: POSCatalogIncrementalSyncServiceProtocol { - var startIncrementalSyncResult: Result = .success(()) + var startIncrementalSyncResult: Result = .success(POSCatalog(products: [], variations: [], syncDate: .now)) private(set) var startIncrementalSyncCallCount = 0 private(set) var lastSyncSiteID: Int64? @@ -13,7 +13,7 @@ final class MockPOSCatalogIncrementalSyncService: POSCatalogIncrementalSyncServi private var shouldBlockSync = false private var syncBlockedContinuations: [CheckedContinuation] = [] - func startIncrementalSync(for siteID: Int64, lastFullSyncDate: Date, lastIncrementalSyncDate: Date?) async throws { + func startIncrementalSync(for siteID: Int64, lastFullSyncDate: Date, lastIncrementalSyncDate: Date?) async throws -> POSCatalog { startIncrementalSyncCallCount += 1 lastSyncSiteID = siteID self.lastFullSyncDate = lastFullSyncDate @@ -30,8 +30,8 @@ final class MockPOSCatalogIncrementalSyncService: POSCatalogIncrementalSyncServi } switch startIncrementalSyncResult { - case .success: - return + case .success(let catalog): + return catalog case .failure(let error): throw error } diff --git a/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogSyncCoordinatorTests.swift b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogSyncCoordinatorTests.swift index 8749f3f9ba6..1dfd8b5f063 100644 --- a/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogSyncCoordinatorTests.swift +++ b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogSyncCoordinatorTests.swift @@ -426,7 +426,7 @@ struct POSCatalogSyncCoordinatorTests { } // Then - subsequent incremental sync should be allowed - mockIncrementalSyncService.startIncrementalSyncResult = .success(()) + mockIncrementalSyncService.startIncrementalSyncResult = .success(POSCatalog(products: [], variations: [], syncDate: .now)) try await sut.performIncrementalSyncIfApplicable(for: sampleSiteID, maxAge: sampleMaxAge) #expect(mockIncrementalSyncService.startIncrementalSyncCallCount == 2) @@ -757,7 +757,7 @@ extension POSCatalogSyncCoordinatorTests { ) let twoHoursAgo = Date().addingTimeInterval(-2 * 60 * 60) try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: twoHoursAgo) - mockIncrementalSyncService.startIncrementalSyncResult = .success(()) + mockIncrementalSyncService.startIncrementalSyncResult = .success(POSCatalog(products: [], variations: [], syncDate: .now)) // When try await coordinator.performSmartSync(for: sampleSiteID) @@ -804,7 +804,7 @@ extension POSCatalogSyncCoordinatorTests { siteSettings: mockSiteSettings ) try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: Date().addingTimeInterval(-2 * 60 * 60)) - mockIncrementalSyncService.startIncrementalSyncResult = .success(()) + mockIncrementalSyncService.startIncrementalSyncResult = .success(POSCatalog(products: [], variations: [], syncDate: .now)) // When try await coordinator.performSmartSync(for: sampleSiteID) @@ -851,7 +851,7 @@ extension POSCatalogSyncCoordinatorTests { siteSettings: mockSiteSettings ) try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: Date().addingTimeInterval(-2 * 60 * 60)) - mockIncrementalSyncService.startIncrementalSyncResult = .success(()) + mockIncrementalSyncService.startIncrementalSyncResult = .success(POSCatalog(products: [], variations: [], syncDate: .now)) // When try await coordinator.performSmartSync(for: sampleSiteID) @@ -875,7 +875,7 @@ extension POSCatalogSyncCoordinatorTests { siteSettings: mockSiteSettings ) try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: Date().addingTimeInterval(-2 * 60 * 60)) - mockIncrementalSyncService.startIncrementalSyncResult = .success(()) + mockIncrementalSyncService.startIncrementalSyncResult = .success(POSCatalog(products: [], variations: [], syncDate: .now)) // When try await coordinator.performSmartSync(for: sampleSiteID) @@ -982,7 +982,7 @@ extension POSCatalogSyncCoordinatorTests { mockIncrementalSyncService.resumeBlockedSync() _ = try? await syncTask.value - mockIncrementalSyncService.startIncrementalSyncResult = .success(()) + mockIncrementalSyncService.startIncrementalSyncResult = .success(POSCatalog(products: [], variations: [], syncDate: .now)) try await sut.performIncrementalSyncIfApplicable(for: sampleSiteID, maxAge: sampleMaxAge) #expect(mockIncrementalSyncService.startIncrementalSyncCallCount == 2) } @@ -1080,7 +1080,7 @@ extension POSCatalogSyncCoordinatorTests { _ = try? await syncTaskA.value _ = try? await syncTaskB.value - mockIncrementalSyncService.startIncrementalSyncResult = .success(()) + mockIncrementalSyncService.startIncrementalSyncResult = .success(POSCatalog(products: [], variations: [], syncDate: .now)) try await sut.performIncrementalSyncIfApplicable(for: siteA, maxAge: sampleMaxAge) } @@ -1134,6 +1134,227 @@ extension POSCatalogSyncCoordinatorTests { #expect(mockSyncService.lastAllowCellular == true) // Setting should not be checked for first sync (it's overridden) } + + // MARK: - Analytics Tests + + @Test func performFullSyncIfApplicable_tracks_analytics_events() async throws { + // Given + let mockAnalytics = MockAnalytics() + let sut = POSCatalogSyncCoordinator( + fullSyncService: mockSyncService, + incrementalSyncService: mockIncrementalSyncService, + grdbManager: grdbManager, + catalogEligibilityChecker: mockEligibilityChecker, + siteSettings: mockSiteSettings, + analytics: mockAnalytics + ) + + // When + try await sut.performFullSyncIfApplicable(for: sampleSiteID, maxAge: sampleMaxAge) + + // Then - Verify sync started event + let syncStarted = mockAnalytics.trackedEvents.first { $0.eventName == "local_catalog_sync_started" } + #expect(syncStarted != nil) + + // Then - Verify sync completed event + let syncCompleted = mockAnalytics.trackedEvents.first { $0.eventName == "local_catalog_sync_completed" } + #expect(syncCompleted != nil) + } + + @Test func performFullSyncIfApplicable_tracks_synced_product_and_variation_counts() async throws { + // Given + let mockAnalytics = MockAnalytics() + let sut = POSCatalogSyncCoordinator( + fullSyncService: mockSyncService, + incrementalSyncService: mockIncrementalSyncService, + grdbManager: grdbManager, + catalogEligibilityChecker: mockEligibilityChecker, + siteSettings: mockSiteSettings, + analytics: mockAnalytics + ) + + // Set up mock to return a catalog with specific counts + let syncedProducts = [POSProduct.fake(), POSProduct.fake(), POSProduct.fake()] + let syncedVariations = [POSProductVariation.fake()] + mockSyncService.startFullSyncResult = .success( + POSCatalog(products: syncedProducts, variations: syncedVariations, syncDate: .now) + ) + + // When + try await sut.performFullSyncIfApplicable(for: sampleSiteID, maxAge: sampleMaxAge) + + // Then + let syncCompleted = mockAnalytics.trackedEvents.first { $0.eventName == "local_catalog_sync_completed" } + #expect(syncCompleted != nil) + #expect(syncCompleted?.properties?["products_synced"] as? String == "3") + #expect(syncCompleted?.properties?["variations_synced"] as? String == "1") + } + + @Test func performFullSyncIfApplicable_tracks_sync_failed_with_error_type() async throws { + // Given + let mockAnalytics = MockAnalytics() + let sut = POSCatalogSyncCoordinator( + fullSyncService: mockSyncService, + incrementalSyncService: mockIncrementalSyncService, + grdbManager: grdbManager, + catalogEligibilityChecker: mockEligibilityChecker, + siteSettings: mockSiteSettings, + analytics: mockAnalytics + ) + mockSyncService.startFullSyncResult = .failure(NSError(domain: "NetworkingCore.NetworkError", code: 500)) + + // When + try? await sut.performFullSyncIfApplicable(for: sampleSiteID, maxAge: sampleMaxAge) + + // Then + let syncFailed = mockAnalytics.trackedEvents.first { $0.eventName == "local_catalog_sync_failed" } + #expect(syncFailed != nil) + #expect(syncFailed?.properties?["error_type"] as? String == "network_error") + } + + @Test func performFullSyncIfApplicable_tracks_database_error_type() async throws { + // Given + let mockAnalytics = MockAnalytics() + let sut = POSCatalogSyncCoordinator( + fullSyncService: mockSyncService, + incrementalSyncService: mockIncrementalSyncService, + grdbManager: grdbManager, + catalogEligibilityChecker: mockEligibilityChecker, + siteSettings: mockSiteSettings, + analytics: mockAnalytics + ) + // Simulate a GRDB database error by using domain "GRDB.DatabaseError" + mockSyncService.startFullSyncResult = .failure(NSError(domain: "GRDB.DatabaseError", code: 1)) + + // When + try? await sut.performFullSyncIfApplicable(for: sampleSiteID, maxAge: sampleMaxAge) + + // Then + let syncFailed = mockAnalytics.trackedEvents.first { $0.eventName == "local_catalog_sync_failed" } + #expect(syncFailed != nil) + #expect(syncFailed?.properties?["error_type"] as? String == "database_error") + } + + @Test func performFullSyncIfApplicable_tracks_insufficient_space_for_sqlite_full_error() async throws { + // Given + let mockAnalytics = MockAnalytics() + let sut = POSCatalogSyncCoordinator( + fullSyncService: mockSyncService, + incrementalSyncService: mockIncrementalSyncService, + grdbManager: grdbManager, + catalogEligibilityChecker: mockEligibilityChecker, + siteSettings: mockSiteSettings, + analytics: mockAnalytics + ) + // Simulate SQLITE_FULL error (code 13 = disk full) + mockSyncService.startFullSyncResult = .failure(NSError(domain: "GRDB.DatabaseError", code: 13)) + + // When + try? await sut.performFullSyncIfApplicable(for: sampleSiteID, maxAge: sampleMaxAge) + + // Then + let syncFailed = mockAnalytics.trackedEvents.first { $0.eventName == "local_catalog_sync_failed" } + #expect(syncFailed != nil) + #expect(syncFailed?.properties?["error_type"] as? String == "insufficient_free_space") + } + + @Test func performIncrementalSyncIfApplicable_tracks_analytics_events() async throws { + // Given + let mockAnalytics = MockAnalytics() + let sut = POSCatalogSyncCoordinator( + fullSyncService: mockSyncService, + incrementalSyncService: mockIncrementalSyncService, + grdbManager: grdbManager, + catalogEligibilityChecker: mockEligibilityChecker, + siteSettings: mockSiteSettings, + analytics: mockAnalytics + ) + try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: Date().addingTimeInterval(-30 * 60)) + + // When + try await sut.performIncrementalSyncIfApplicable(for: sampleSiteID, maxAge: sampleMaxAge) + + // Then + let syncStarted = mockAnalytics.trackedEvents.first { $0.eventName == "local_catalog_sync_started" } + #expect(syncStarted != nil) + let syncCompleted = mockAnalytics.trackedEvents.first { $0.eventName == "local_catalog_sync_completed" } + #expect(syncCompleted != nil) + } + + @Test func performIncrementalSyncIfApplicable_tracks_synced_product_and_variation_counts() async throws { + // Given + let mockAnalytics = MockAnalytics() + let sut = POSCatalogSyncCoordinator( + fullSyncService: mockSyncService, + incrementalSyncService: mockIncrementalSyncService, + grdbManager: grdbManager, + catalogEligibilityChecker: mockEligibilityChecker, + siteSettings: mockSiteSettings, + analytics: mockAnalytics + ) + try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: Date().addingTimeInterval(-30 * 60)) + + // Set up mock to return a catalog with specific counts + let syncedProducts = [POSProduct.fake(), POSProduct.fake()] + let syncedVariations = [POSProductVariation.fake(), POSProductVariation.fake(), POSProductVariation.fake()] + mockIncrementalSyncService.startIncrementalSyncResult = .success( + POSCatalog(products: syncedProducts, variations: syncedVariations, syncDate: .now) + ) + + // When + try await sut.performIncrementalSyncIfApplicable(for: sampleSiteID, maxAge: sampleMaxAge) + + // Then + let syncCompleted = mockAnalytics.trackedEvents.first { $0.eventName == "local_catalog_sync_completed" } + #expect(syncCompleted != nil) + #expect(syncCompleted?.properties?["products_synced"] as? String == "2") + #expect(syncCompleted?.properties?["variations_synced"] as? String == "3") + } + + @Test func performIncrementalSyncIfApplicable_tracks_sync_skipped_when_no_full_sync() async throws { + // Given + let mockAnalytics = MockAnalytics() + let sut = POSCatalogSyncCoordinator( + fullSyncService: mockSyncService, + incrementalSyncService: mockIncrementalSyncService, + grdbManager: grdbManager, + catalogEligibilityChecker: mockEligibilityChecker, + siteSettings: mockSiteSettings, + analytics: mockAnalytics + ) + // No full sync exists for this site + + // When + try await sut.performIncrementalSyncIfApplicable(for: sampleSiteID, maxAge: sampleMaxAge) + + // Then + let syncSkipped = mockAnalytics.trackedEvents.first { $0.eventName == "local_catalog_sync_skipped" } + #expect(syncSkipped != nil) + #expect(syncSkipped?.properties?["reason"] as? String == "no_full_sync") + } + + @Test func performFullSyncIfApplicable_tracks_sync_skipped_when_not_stale() async throws { + // Given + let mockAnalytics = MockAnalytics() + let sut = POSCatalogSyncCoordinator( + fullSyncService: mockSyncService, + incrementalSyncService: mockIncrementalSyncService, + grdbManager: grdbManager, + catalogEligibilityChecker: mockEligibilityChecker, + siteSettings: mockSiteSettings, + analytics: mockAnalytics + ) + // Recent sync exists + try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: Date()) + + // When + try? await sut.performFullSyncIfApplicable(for: sampleSiteID, maxAge: sampleMaxAge) + + // Then + let syncSkipped = mockAnalytics.trackedEvents.first { $0.eventName == "local_catalog_sync_skipped" } + #expect(syncSkipped != nil) + #expect(syncSkipped?.properties?["reason"] as? String == "catalog_not_stale") + } } extension POSCatalogSyncCoordinator { diff --git a/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogSyncErrorClassifierTests.swift b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogSyncErrorClassifierTests.swift new file mode 100644 index 00000000000..49324658f2e --- /dev/null +++ b/Modules/Tests/YosemiteTests/Tools/POS/POSCatalogSyncErrorClassifierTests.swift @@ -0,0 +1,151 @@ +import Foundation +import Testing +import GRDB +import Alamofire +@testable import Yosemite + +struct POSCatalogSyncErrorClassifierTests { + @Test func classify_database_full_error_returns_insufficient_space() { + // Given + let error = DatabaseError(resultCode: .SQLITE_FULL) + + // When + let result = POSCatalogSyncErrorClassifier.classify(error) + + // Then + #expect(result == "insufficient_free_space") + } + + @Test func classify_database_corrupt_error_returns_database_corruption() { + // Given + let error = DatabaseError(resultCode: .SQLITE_CORRUPT) + + // When + let result = POSCatalogSyncErrorClassifier.classify(error) + + // Then + #expect(result == "database_corruption") + } + + @Test func classify_database_constraint_error_returns_constraint_violation() { + // Given + let error = DatabaseError(resultCode: .SQLITE_CONSTRAINT) + + // When + let result = POSCatalogSyncErrorClassifier.classify(error) + + // Then + #expect(result == "database_constraint_violation") + } + + @Test func classify_database_io_error_returns_database_error() { + // Given + let error = DatabaseError(resultCode: .SQLITE_IOERR) + + // When + let result = POSCatalogSyncErrorClassifier.classify(error) + + // Then + #expect(result == "database_error") + } + + @Test func classify_cancellation_error_returns_request_cancelled() { + // Given + let error = CancellationError() + + // When + let result = POSCatalogSyncErrorClassifier.classify(error) + + // Then + #expect(result == "request_cancelled") + } + + @Test func classify_url_error_not_connected_returns_network_error() { + // Given + let error = URLError(.notConnectedToInternet) + + // When + let result = POSCatalogSyncErrorClassifier.classify(error) + + // Then + #expect(result == "network_error") + } + + @Test func classify_url_error_timeout_returns_network_timeout() { + // Given + let error = URLError(.timedOut) + + // When + let result = POSCatalogSyncErrorClassifier.classify(error) + + // Then + #expect(result == "network_timeout") + } + + @Test func classify_decoding_error_returns_catalog_integrity() { + // Given + struct TestModel: Decodable { + let field: String + } + let jsonData = Data("{}".utf8) + let decoder = JSONDecoder() + var decodingError: Error? + do { + _ = try decoder.decode(TestModel.self, from: jsonData) + } catch { + decodingError = error + } + + // When + let result = POSCatalogSyncErrorClassifier.classify(decodingError!) + + // Then + #expect(result == "catalog_integrity") + } + + @Test func classify_authentication_error_returns_authentication_error() { + // Given + let error = NSError(domain: "Test", code: 401, userInfo: nil) + + // When + let result = POSCatalogSyncErrorClassifier.classify(error) + + // Then + #expect(result == "authentication_error") + } + + @Test func classify_unknown_error_returns_unexpected_error() { + // Given + struct CustomError: Error {} + let error = CustomError() + + // When + let result = POSCatalogSyncErrorClassifier.classify(error) + + // Then + #expect(result == "unexpected_error") + } + + @Test func classify_afError_wrapping_url_error_returns_network_error() { + // Given + let urlError = URLError(.notConnectedToInternet) + let afError = AFError.sessionTaskFailed(error: urlError) + + // When + let result = POSCatalogSyncErrorClassifier.classify(afError) + + // Then + #expect(result == "network_error") + } + + @Test func classify_afError_with_401_status_returns_authentication_error() { + // Given + let afError = AFError.responseValidationFailed(reason: .unacceptableStatusCode(code: 401)) + + // When + let result = POSCatalogSyncErrorClassifier.classify(afError) + + // Then + #expect(result == "authentication_error") + } +} diff --git a/WooCommerce/Classes/Analytics/TracksProvider.swift b/WooCommerce/Classes/Analytics/TracksProvider.swift index 719ca73af69..9340dfc9164 100644 --- a/WooCommerce/Classes/Analytics/TracksProvider.swift +++ b/WooCommerce/Classes/Analytics/TracksProvider.swift @@ -224,10 +224,38 @@ private extension TracksProvider { WooAnalyticsStat.pointOfSaleSettingsStoreDetailsTapped, WooAnalyticsStat.pointOfSaleSettingsHardwareTapped, WooAnalyticsStat.pointOfSaleSettingsHelpTapped, - WooAnalyticsStat.pointOfSaleEmptyCartSetupScannerTapped + WooAnalyticsStat.pointOfSaleEmptyCartSetupScannerTapped, + + // Catalog + WooAnalyticsStat.pointOfSaleLocalCatalogDownloadingScreenShown, + WooAnalyticsStat.pointOfSaleLocalCatalogDownloadingScreenExitPosTapped, + WooAnalyticsStat.pointOfSaleSplashScreenErrorShown, + WooAnalyticsStat.pointOfSaleSplashScreenRetryTapped, + WooAnalyticsStat.pointOfSaleLocalCatalogStaleWarningShown, + WooAnalyticsStat.pointOfSaleLocalCatalogStaleWarningDismissed, + WooAnalyticsStat.pointOfSaleLocalCatalogSyncStarted, + WooAnalyticsStat.pointOfSaleLocalCatalogSyncCompleted, + WooAnalyticsStat.pointOfSaleLocalCatalogSyncFailed, + WooAnalyticsStat.pointOfSaleLocalCatalogSyncSkipped ] - guard Self.isPOSModeActive, pointOfSaleEventList.contains(event) else { + // Local catalog events always get pos_ prefix since they're POS-specific features + // that can run in background regardless of whether POS tab is active + let localCatalogEventList: Set = [ + WooAnalyticsStat.pointOfSaleLocalCatalogDownloadingScreenShown, + WooAnalyticsStat.pointOfSaleLocalCatalogDownloadingScreenExitPosTapped, + WooAnalyticsStat.pointOfSaleSplashScreenErrorShown, + WooAnalyticsStat.pointOfSaleSplashScreenRetryTapped, + WooAnalyticsStat.pointOfSaleLocalCatalogStaleWarningShown, + WooAnalyticsStat.pointOfSaleLocalCatalogStaleWarningDismissed, + WooAnalyticsStat.pointOfSaleLocalCatalogSyncStarted, + WooAnalyticsStat.pointOfSaleLocalCatalogSyncCompleted, + WooAnalyticsStat.pointOfSaleLocalCatalogSyncFailed, + WooAnalyticsStat.pointOfSaleLocalCatalogSyncSkipped + ] + + // Apply prefix if: (POS mode is active AND event is in the list) OR event is a local catalog event + guard (Self.isPOSModeActive && pointOfSaleEventList.contains(event)) || localCatalogEventList.contains(event) else { return eventName } let prefix = "pos_" diff --git a/WooCommerce/Classes/Yosemite/AuthenticatedState.swift b/WooCommerce/Classes/Yosemite/AuthenticatedState.swift index bb1e7a39823..9714b252fc5 100644 --- a/WooCommerce/Classes/Yosemite/AuthenticatedState.swift +++ b/WooCommerce/Classes/Yosemite/AuthenticatedState.swift @@ -204,7 +204,9 @@ class AuthenticatedState: StoresManagerState { fullSyncService: fullSyncService, incrementalSyncService: incrementalSyncService, grdbManager: ServiceLocator.grdbManager, - catalogEligibilityChecker: eligibilityService + catalogEligibilityChecker: eligibilityService, + analytics: ServiceLocator.analytics, + connectivityObserver: ServiceLocator.connectivityObserver ) // Note: POS eligibility will be set later by POSTabCoordinator.updatePOSEligibility diff --git a/WooCommerce/WooCommerceTests/Tools/ForegroundPOSCatalogSyncDispatcherTests.swift b/WooCommerce/WooCommerceTests/Tools/ForegroundPOSCatalogSyncDispatcherTests.swift index 922edf6ad23..d234672d808 100644 --- a/WooCommerce/WooCommerceTests/Tools/ForegroundPOSCatalogSyncDispatcherTests.swift +++ b/WooCommerce/WooCommerceTests/Tools/ForegroundPOSCatalogSyncDispatcherTests.swift @@ -302,6 +302,10 @@ private final class MockPOSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProt return false } + func hoursSinceLastSync(for siteID: Int64) async -> Int? { + return nil + } + func stopOngoingSyncs(for siteID: Int64) async {} func processBackgroundDownload(fileURL: URL, siteID: Int64) async throws {