Skip to content

Commit dc27720

Browse files
authored
[Local catalog] Use specific skip reasons in analytics (#16382)
2 parents 33ee78b + 15cfef7 commit dc27720

File tree

5 files changed

+195
-18
lines changed

5 files changed

+195
-18
lines changed

Modules/Sources/WooFoundationCore/Analytics/WooAnalyticsEvent.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,9 +139,10 @@ extension WooAnalyticsEvent {
139139
error: error)
140140
}
141141

142-
public static func syncSkipped(reason: String) -> WooAnalyticsEvent {
142+
public static func syncSkipped(reason: String, syncType: String) -> WooAnalyticsEvent {
143143
WooAnalyticsEvent(statName: .pointOfSaleLocalCatalogSyncSkipped,
144-
properties: [Key.reason: reason])
144+
properties: [Key.reason: reason,
145+
Key.syncType: syncType])
145146
}
146147
}
147148
}

Modules/Sources/Yosemite/Protocols/POSLocalCatalogEligibilityServiceProtocol.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ public enum POSLocalCatalogIneligibleReason: Equatable {
2222
public var skipReason: String {
2323
switch self {
2424
case .posTabNotEligible:
25-
return "pos_inactive"
25+
return "pos_not_eligible"
2626
case .featureFlagDisabled:
2727
return "feature_flag_disabled"
2828
case .unsupportedWooCommerceVersion:

Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift

Lines changed: 82 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,8 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {
149149

150150
guard try await shouldPerformFullSync(for: siteID, maxAge: maxAge) else {
151151
let reason = await getSyncSkipReason(for: siteID, maxAge: maxAge)
152-
trackAnalytics(WooAnalyticsEvent.LocalCatalog.syncSkipped(reason: reason))
152+
trackAnalytics(WooAnalyticsEvent.LocalCatalog.syncSkipped(reason: reason,
153+
syncType: POSCatalogSyncType.full.rawValue))
153154
throw POSCatalogSyncError.shouldNotSync
154155
}
155156

@@ -315,7 +316,8 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {
315316

316317
guard try await shouldPerformIncrementalSync(for: siteID, maxAge: maxAge) else {
317318
let reason = await getIncrementalSyncSkipReason(for: siteID, maxAge: maxAge)
318-
trackAnalytics(WooAnalyticsEvent.LocalCatalog.syncSkipped(reason: reason))
319+
trackAnalytics(WooAnalyticsEvent.LocalCatalog.syncSkipped(reason: reason,
320+
syncType: POSCatalogSyncType.incremental.rawValue))
319321
return
320322
}
321323

@@ -596,6 +598,16 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {
596598
return "eligibility_check_failed"
597599
}
598600

601+
// Check sync eligibility
602+
do {
603+
let eligibility = try await syncEligibility(for: siteID)
604+
if case .ineligible(let reason) = eligibility {
605+
return reason.skipReason
606+
}
607+
} catch {
608+
return "sync_eligibility_check_failed"
609+
}
610+
599611
// Check if sync is needed based on age
600612
guard let lastSyncDate = await lastFullSyncDate(for: siteID) else {
601613
return "no_previous_sync" // This shouldn't happen if shouldPerformFullSync returned false
@@ -610,7 +622,7 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {
610622
}
611623

612624
private func getIncrementalSyncSkipReason(for siteID: Int64, maxAge: TimeInterval) async -> String {
613-
// Check eligibility first
625+
// Check overall local catalog eligibility first
614626
do {
615627
let eligibility = try await catalogEligibilityChecker.catalogEligibility(for: siteID)
616628
if case .ineligible(let reason) = eligibility {
@@ -620,6 +632,16 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {
620632
return "eligibility_check_failed"
621633
}
622634

635+
// Check sync eligibility
636+
do {
637+
let eligibility = try await syncEligibility(for: siteID)
638+
if case .ineligible(let reason) = eligibility {
639+
return reason.skipReason
640+
}
641+
} catch {
642+
return "sync_eligibility_check_failed"
643+
}
644+
623645
// Check if full sync exists
624646
guard await lastFullSyncDate(for: siteID) != nil else {
625647
return "no_full_sync"
@@ -703,19 +725,43 @@ private extension POSCatalogSyncCoordinator {
703725

704726
/// Checks if sync is eligible for the given site based on catalog eligibility and temporal criteria
705727
func checkSyncEligibility(for siteID: Int64) async throws -> Bool {
706-
guard try await catalogEligibilityChecker.catalogEligibility(for: siteID) == .eligible else {
707-
DDLogInfo("📋 POSCatalogSyncCoordinator: Site \(siteID) - Catalog ineligible")
728+
switch try await syncEligibility(for: siteID) {
729+
case .eligible(reason: let reason):
730+
switch reason {
731+
case .neverSynced:
732+
DDLogInfo("📋 POSCatalogSyncCoordinator: Site \(siteID) eligible (no first sync date recorded)")
733+
case .openedRecently(daysSinceOpened: let daysSinceOpened):
734+
DDLogInfo("📋 POSCatalogSyncCoordinator: Site \(siteID) eligible (last opened \(daysSinceOpened) days ago)")
735+
case .recentFirstSync(daysSinceFirstSync: let daysSinceFirstSync):
736+
DDLogInfo("📋 POSCatalogSyncCoordinator: Site \(siteID) eligible (within grace period: \(daysSinceFirstSync) days since first sync)")
737+
}
738+
return true
739+
case .ineligible(reason: let reason):
740+
switch reason {
741+
case .notEligibleForLocalCatalog:
742+
DDLogInfo("📋 POSCatalogSyncCoordinator: Site \(siteID) - Catalog ineligible")
743+
case .notOpenedRecently(daysSinceOpened: let daysSinceOpened):
744+
DDLogInfo("📋 POSCatalogSyncCoordinator: Site \(siteID) ineligible (POS last opened \(daysSinceOpened) days ago)")
745+
case .neverOpenedAndPastFirstSyncGracePeriod(daysSinceFirstSync: let daysSinceFirstSync):
746+
DDLogInfo("📋 POSCatalogSyncCoordinator: Site \(siteID) ineligible (past 30-day grace period, " +
747+
"no recent POS open, \(daysSinceFirstSync) days since first sync)")
748+
}
708749
return false
709750
}
751+
}
752+
753+
func syncEligibility(for siteID: Int64) async throws -> SyncEligibility {
754+
guard try await catalogEligibilityChecker.catalogEligibility(for: siteID) == .eligible else {
755+
return .ineligible(reason: .notEligibleForLocalCatalog)
756+
}
710757

711758
// Then check temporal eligibility (30-day criteria)
712759
let firstSyncDate = siteSettings.getFirstPOSCatalogSyncDate(siteID: siteID)
713760
let lastOpenedDate = siteSettings.getPOSLastOpenedDate(siteID: siteID)
714761

715762
// Case 1: No first sync date yet - eligible (will be set on first sync)
716763
guard let firstSync = firstSyncDate else {
717-
DDLogInfo("📋 POSCatalogSyncCoordinator: Site \(siteID) eligible (no first sync date recorded)")
718-
return true
764+
return .eligible(reason: .neverSynced)
719765
}
720766

721767
// Case 2: Has synced before. Check if within 30-day window from first sync
@@ -724,23 +770,44 @@ private extension POSCatalogSyncCoordinator {
724770
if daysSinceFirstSync > Constants.maxDaysSinceLastOpened {
725771
// More than 30 days since first sync - must have opened POS recently to remain eligible
726772
guard let lastOpened = lastOpenedDate else {
727-
DDLogInfo("📋 POSCatalogSyncCoordinator: Site \(siteID) ineligible (past 30-day grace period, no recent POS open)")
728-
return false
773+
return .ineligible(reason: .neverOpenedAndPastFirstSyncGracePeriod(daysSinceFirstSync: daysSinceFirstSync))
729774
}
730775

731776
let daysSinceLastOpened = Calendar.current.dateComponents([.day], from: lastOpened, to: Date()).day ?? 0
732777

733778
if daysSinceLastOpened <= Constants.maxDaysSinceLastOpened {
734-
DDLogInfo("📋 POSCatalogSyncCoordinator: Site \(siteID) eligible (last opened \(daysSinceLastOpened) days ago)")
735-
return true
779+
return .eligible(reason: .openedRecently(daysSinceOpened: daysSinceLastOpened))
736780
} else {
737-
DDLogInfo("📋 POSCatalogSyncCoordinator: Site \(siteID) ineligible (POS last opened \(daysSinceLastOpened) days ago)")
738-
return false
781+
return .ineligible(reason: .notOpenedRecently(daysSinceOpened: daysSinceLastOpened))
739782
}
740783
} else {
741784
// Within 30 days of first sync - always eligible (new user grace period)
742-
DDLogInfo("📋 POSCatalogSyncCoordinator: Site \(siteID) eligible (within grace period: \(daysSinceFirstSync) days since first sync)")
743-
return true
785+
return .eligible(reason: .recentFirstSync(daysSinceFirstSync: daysSinceFirstSync))
786+
}
787+
}
788+
789+
enum SyncEligibility {
790+
case eligible(reason: EligibleReason)
791+
case ineligible(reason: IneligibleReason)
792+
793+
enum EligibleReason {
794+
case neverSynced
795+
case openedRecently(daysSinceOpened: Int)
796+
case recentFirstSync(daysSinceFirstSync: Int)
797+
}
798+
799+
enum IneligibleReason {
800+
case notEligibleForLocalCatalog
801+
case notOpenedRecently(daysSinceOpened: Int)
802+
case neverOpenedAndPastFirstSyncGracePeriod(daysSinceFirstSync: Int)
803+
804+
var skipReason: String {
805+
switch self {
806+
case .notEligibleForLocalCatalog: "not_eligible"
807+
case .notOpenedRecently: "pos_not_opened_30_days"
808+
case .neverOpenedAndPastFirstSyncGracePeriod: "pos_not_opened_30_days"
809+
}
810+
}
744811
}
745812
}
746813

Modules/Tests/YosemiteTests/Tools/POS/POSCatalogSyncCoordinatorTests.swift

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1331,6 +1331,7 @@ extension POSCatalogSyncCoordinatorTests {
13311331
let syncSkipped = mockAnalytics.trackedEvents.first { $0.eventName == "local_catalog_sync_skipped" }
13321332
#expect(syncSkipped != nil)
13331333
#expect(syncSkipped?.properties?["reason"] as? String == "no_full_sync")
1334+
#expect(syncSkipped?.properties?["sync_type"] as? String == "incremental")
13341335
}
13351336

13361337
@Test func performFullSyncIfApplicable_tracks_sync_skipped_when_not_stale() async throws {
@@ -1354,6 +1355,87 @@ extension POSCatalogSyncCoordinatorTests {
13541355
let syncSkipped = mockAnalytics.trackedEvents.first { $0.eventName == "local_catalog_sync_skipped" }
13551356
#expect(syncSkipped != nil)
13561357
#expect(syncSkipped?.properties?["reason"] as? String == "catalog_not_stale")
1358+
#expect(syncSkipped?.properties?["sync_type"] as? String == "full")
1359+
}
1360+
1361+
// MARK: - Sync Type Analytics
1362+
1363+
@Test("POSCatalogSyncType enum has correct raw values")
1364+
func testPOSCatalogSyncTypeRawValues() {
1365+
#expect(POSCatalogSyncType.full.rawValue == "full")
1366+
#expect(POSCatalogSyncType.incremental.rawValue == "incremental")
1367+
}
1368+
1369+
// MARK: - POS Not Opened 30 Days Skip Reason Tests
1370+
1371+
@Test func performFullSyncIfApplicable_tracks_pos_not_opened_30_days_when_not_opened_recently() async throws {
1372+
// Given - Store with first sync > 30 days ago and last opened > 30 days ago
1373+
let mockAnalytics = MockAnalytics()
1374+
let mockSiteSettings = MockSiteSpecificAppSettingsStoreMethods()
1375+
1376+
// Set first sync date to 40 days ago
1377+
let firstSyncDate = Calendar.current.date(byAdding: .day, value: -40, to: Date())!
1378+
mockSiteSettings.setFirstPOSCatalogSyncDate(siteID: sampleSiteID, date: firstSyncDate)
1379+
1380+
// Set last opened date to 35 days ago (more than 30 days)
1381+
let lastOpenedDate = Calendar.current.date(byAdding: .day, value: -35, to: Date())!
1382+
mockSiteSettings.setPOSLastOpenedDate(siteID: sampleSiteID, date: lastOpenedDate)
1383+
1384+
// Create site in database with full sync date
1385+
try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: firstSyncDate)
1386+
1387+
let sut = POSCatalogSyncCoordinator(
1388+
fullSyncService: mockSyncService,
1389+
incrementalSyncService: mockIncrementalSyncService,
1390+
grdbManager: grdbManager,
1391+
catalogEligibilityChecker: mockEligibilityChecker,
1392+
siteSettings: mockSiteSettings,
1393+
analytics: mockAnalytics,
1394+
connectivityObserver: nil
1395+
)
1396+
1397+
// When - Try to perform sync
1398+
try? await sut.performFullSyncIfApplicable(for: sampleSiteID, maxAge: .zero)
1399+
1400+
// Then - Should track pos_not_opened_30_days
1401+
let syncSkipped = mockAnalytics.trackedEvents.first { $0.eventName == "local_catalog_sync_skipped" }
1402+
#expect(syncSkipped != nil)
1403+
#expect(syncSkipped?.properties?["reason"] as? String == "pos_not_opened_30_days")
1404+
#expect(syncSkipped?.properties?["sync_type"] as? String == "full")
1405+
}
1406+
1407+
@Test func performIncrementalSyncIfApplicable_tracks_pos_not_opened_30_days_when_never_opened_and_past_grace_period() async throws {
1408+
// Given - Store with first sync > 30 days ago and never opened POS
1409+
let mockAnalytics = MockAnalytics()
1410+
let mockSiteSettings = MockSiteSpecificAppSettingsStoreMethods()
1411+
1412+
// Set first sync date to 40 days ago
1413+
let firstSyncDate = Calendar.current.date(byAdding: .day, value: -40, to: Date())!
1414+
mockSiteSettings.setFirstPOSCatalogSyncDate(siteID: sampleSiteID, date: firstSyncDate)
1415+
1416+
// Don't set last opened date (nil = never opened)
1417+
1418+
// Create site in database with full sync date
1419+
try createSiteInDatabase(siteID: sampleSiteID, lastFullSyncDate: firstSyncDate)
1420+
1421+
let sut = POSCatalogSyncCoordinator(
1422+
fullSyncService: mockSyncService,
1423+
incrementalSyncService: mockIncrementalSyncService,
1424+
grdbManager: grdbManager,
1425+
catalogEligibilityChecker: mockEligibilityChecker,
1426+
siteSettings: mockSiteSettings,
1427+
analytics: mockAnalytics,
1428+
connectivityObserver: nil
1429+
)
1430+
1431+
// When - Try to perform incremental sync
1432+
try await sut.performIncrementalSyncIfApplicable(for: sampleSiteID, maxAge: .zero)
1433+
1434+
// Then - Should track pos_not_opened_30_days
1435+
let syncSkipped = mockAnalytics.trackedEvents.first { $0.eventName == "local_catalog_sync_skipped" }
1436+
#expect(syncSkipped != nil)
1437+
#expect(syncSkipped?.properties?["reason"] as? String == "pos_not_opened_30_days")
1438+
#expect(syncSkipped?.properties?["sync_type"] as? String == "incremental")
13571439
}
13581440
}
13591441

Modules/Tests/YosemiteTests/Tools/POS/POSLocalCatalogEligibilityServiceTests.swift

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -536,4 +536,31 @@ struct POSLocalCatalogEligibilityServiceTests {
536536
#expect(reason == expectedReason)
537537
#expect(sizeChecker.checkCatalogSizeCallCount == 0)
538538
}
539+
540+
// MARK: - Skip Reason Analytics
541+
542+
@Test("POSLocalCatalogIneligibleReason skipReason returns correct analytics string")
543+
func testSkipReasonReturnsCorrectAnalyticsString() {
544+
#expect(POSLocalCatalogIneligibleReason.posTabNotEligible.skipReason == "pos_not_eligible")
545+
#expect(POSLocalCatalogIneligibleReason.featureFlagDisabled.skipReason == "feature_flag_disabled")
546+
#expect(POSLocalCatalogIneligibleReason.unsupportedWooCommerceVersion(minimumVersion: "10.3.0").skipReason == "unsupported_woocommerce_version")
547+
#expect(POSLocalCatalogIneligibleReason.catalogSizeTooLarge(totalCount: 1500, limit: 1000).skipReason == "catalog_too_large")
548+
#expect(POSLocalCatalogIneligibleReason.catalogSizeCheckFailed(underlyingError: "error").skipReason == "catalog_size_check_failed")
549+
}
550+
551+
@Test("Skip reason strings are consistent regardless of associated values")
552+
func testSkipReasonConsistentRegardlessOfAssociatedValues() {
553+
// Test that associated values don't affect the skip reason string
554+
let version1 = POSLocalCatalogIneligibleReason.unsupportedWooCommerceVersion(minimumVersion: "10.3.0")
555+
let version2 = POSLocalCatalogIneligibleReason.unsupportedWooCommerceVersion(minimumVersion: "11.0.0")
556+
#expect(version1.skipReason == version2.skipReason)
557+
558+
let sizeTooLarge1 = POSLocalCatalogIneligibleReason.catalogSizeTooLarge(totalCount: 1500, limit: 1000)
559+
let sizeTooLarge2 = POSLocalCatalogIneligibleReason.catalogSizeTooLarge(totalCount: 2000, limit: 1000)
560+
#expect(sizeTooLarge1.skipReason == sizeTooLarge2.skipReason)
561+
562+
let checkFailed1 = POSLocalCatalogIneligibleReason.catalogSizeCheckFailed(underlyingError: "error1")
563+
let checkFailed2 = POSLocalCatalogIneligibleReason.catalogSizeCheckFailed(underlyingError: "error2")
564+
#expect(checkFailed1.skipReason == checkFailed2.skipReason)
565+
}
539566
}

0 commit comments

Comments
 (0)