Skip to content
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
769a947
Analytics for local catalog
joshheald Nov 12, 2025
651f1e2
Improve local catalog analytics error handling
joshheald Nov 12, 2025
06400b5
Add splash screen error analytics tracking
joshheald Nov 12, 2025
494b199
Fir lint
joshheald Nov 12, 2025
4d228db
Track error type for catalog sync errors
joshheald Nov 12, 2025
7a5fd94
Add analytics tracking to incremental catalog sync
joshheald Nov 12, 2025
1589150
Add unit tests for local catalog analytics tracking
joshheald Nov 12, 2025
9775114
Remove unnecessary periphery ignores
joshheald Nov 12, 2025
166e309
Track database full errors
joshheald Nov 12, 2025
997ffaf
Classify errors based on their types
joshheald Nov 12, 2025
e87b5f7
Fix lint, unused code, and mistaken testing commit
joshheald Nov 12, 2025
f41d046
Merge branch 'feat/WOOMOB-1173-background-catalog-download-updated' i…
joshheald Nov 12, 2025
9ffb8bb
Improve test timing/blocking approach
joshheald Nov 12, 2025
24615de
Merge branch 'feat/WOOMOB-1173-parse-catalog-downloads-in-the-backgro…
joshheald Nov 12, 2025
aac839e
Merge branch 'feat/WOOMOB-1173-parse-catalog-downloads-in-the-backgro…
joshheald Nov 12, 2025
cd8784e
Improve classification of AFErrors
joshheald Nov 12, 2025
93604af
Decorate catalog analytics with POS
joshheald Nov 12, 2025
e110275
Track skipped syncs
joshheald Nov 12, 2025
8071a33
Ensure catalog events are tracked even when POS not active
joshheald Nov 12, 2025
83b3161
Track incremental sync counts
joshheald Nov 13, 2025
eaccdda
Track full sync counts
joshheald Nov 13, 2025
1b19fa9
Update tests to return catalog
joshheald Nov 13, 2025
5001954
Use task to ensure we only log syncing event once per cycle
joshheald Nov 17, 2025
4a05010
Simplify error classification by removing redundant switch cases
joshheald Nov 17, 2025
1a2b55b
Refactor WaitingTimeTracker to follow DRY principle
joshheald Nov 17, 2025
f40615d
Add type safety and reduce code duplication in sync coordinator
joshheald Nov 17, 2025
50e3729
Remove explicit nil parameter in coordinator initialization
joshheald Nov 17, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<AnyCancellable> = []

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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)?

Expand All @@ -24,6 +27,7 @@ struct PointOfSaleLoadingView: View {
Spacer()
VStack(spacing: POSSpacing.medium) {
Button {
analytics.track(event: WooAnalyticsEvent.LocalCatalog.downloadingScreenExitPosTapped())
onExit?()
} label: {
Text(Localization.exitButtonTitle)
Expand All @@ -44,6 +48,11 @@ struct PointOfSaleLoadingView: View {
Spacer()
}
.background(Color.posSurface)
.onAppear {
if isCatalogSyncing {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we do this on the view's init to avoid tracking multiple events on potential view recreation?

analytics.track(event: WooAnalyticsEvent.LocalCatalog.downloadingScreenShown())
}
}
}
}

Expand Down
8 changes: 8 additions & 0 deletions Modules/Sources/PointOfSale/Presentation/ItemListView.swift
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -195,13 +196,20 @@ struct ItemListView: View {
title: Localization.staleSyncWarningTitle,
icon: Image(systemName: "info.circle"),
onDismiss: {
analytics.track(event: WooAnalyticsEvent.LocalCatalog.staleWarningDismissed())
withAnimation {
posModel.dismissStaleSyncWarning()
}
}, content: {
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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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)?
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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())
}
}
}
}

Expand Down
5 changes: 5 additions & 0 deletions Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
39 changes: 30 additions & 9 deletions Modules/Sources/WooFoundation/Utilities/WaitingTimeTracker.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -66,20 +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 }

switch scenario {
case .orderDetails:
return WooAnalyticsEvent(statName: .orderDetailWaitingTimeLoaded, properties: [Keys.waitingTime: elapsedTime])
return WooAnalyticsEvent(
statName: .orderDetailWaitingTimeLoaded,
properties: [Keys.waitingTime: elapsedTime].merging(typedAdditionalProperties) { $1 })
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All of them seem to be using waiting time + additional props, we could declare this at the top:

            let props = [Keys.waitingTime: elapsedTime]
                .merging(additionalProperties.mapValues { $0 as WooAnalyticsEventPropertyType }) { $1 }

            switch scenario {
            case .orderDetails:
                return WooAnalyticsEvent(
                    statName: .orderDetailWaitingTimeLoaded,
                    properties: props)

And update the case for pointOfSaleLoaded

case .dashboardTopPerformers:
return WooAnalyticsEvent(statName: .dashboardTopPerformersWaitingTimeLoaded, properties: [Keys.waitingTime: elapsedTime])
return WooAnalyticsEvent(
statName: .dashboardTopPerformersWaitingTimeLoaded,
properties: [Keys.waitingTime: elapsedTime].merging(typedAdditionalProperties) { $1 })
case .dashboardMainStats:
return WooAnalyticsEvent(statName: .dashboardMainStatsWaitingTimeLoaded, properties: [Keys.waitingTime: elapsedTime])
return WooAnalyticsEvent(
statName: .dashboardMainStatsWaitingTimeLoaded,
properties: [Keys.waitingTime: elapsedTime].merging(typedAdditionalProperties) { $1 })
case .analyticsHub:
return WooAnalyticsEvent(statName: .analyticsHubWaitingTimeLoaded, properties: [Keys.waitingTime: elapsedTime])
return WooAnalyticsEvent(
statName: .analyticsHubWaitingTimeLoaded,
properties: [Keys.waitingTime: elapsedTime].merging(typedAdditionalProperties) { $1 })
case .appStartup:
return WooAnalyticsEvent(statName: .applicationOpenedWaitingTimeLoaded, properties: [Keys.waitingTime: elapsedTime])
return WooAnalyticsEvent(
statName: .applicationOpenedWaitingTimeLoaded,
properties: [Keys.waitingTime: elapsedTime].merging(typedAdditionalProperties) { $1 })
case .pointOfSaleLoaded:
return WooAnalyticsEvent(statName: .pointOfSaleLoaded, properties: [Keys.millisecondsTimeElapsedInSplashScreen: elapsedTime])
let properties: [String: WooAnalyticsEventPropertyType] =
[Keys.millisecondsTimeElapsedInSplashScreen: elapsedTime]
return WooAnalyticsEvent(
statName: .pointOfSaleLoaded,
properties: properties.merging(typedAdditionalProperties) { $1 })
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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])
}
}
}
10 changes: 10 additions & 0 deletions Modules/Sources/WooFoundationCore/Analytics/WooAnalyticsStat.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)")
Expand All @@ -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
Expand Down
Loading