From 9d133219bf85d1cf091eb1380917a2b20d0b283f Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Thu, 20 Nov 2025 10:22:31 +0000 Subject: [PATCH 01/20] Add SearchDebounceStrategy enum for configurable search debouncing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a new enum to define different debouncing behaviors for search operations: - `.smart(duration)`: Skip debounce on first keystroke after search completes, then debounce subsequent keystrokes. Optimized for slow network searches. - `.simple(duration, loadingDelayThreshold?)`: Always debounce every keystroke. Optionally delays showing loading indicators until threshold is exceeded. Optimized for fast local searches. - `.immediate`: No debouncing for non-search operations. This allows different search types (local vs remote) to use appropriate debouncing strategies tailored to their performance characteristics. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Items/SearchDebounceStrategy.swift | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 Modules/Sources/Yosemite/PointOfSale/Items/SearchDebounceStrategy.swift diff --git a/Modules/Sources/Yosemite/PointOfSale/Items/SearchDebounceStrategy.swift b/Modules/Sources/Yosemite/PointOfSale/Items/SearchDebounceStrategy.swift new file mode 100644 index 00000000000..c6c1846360a --- /dev/null +++ b/Modules/Sources/Yosemite/PointOfSale/Items/SearchDebounceStrategy.swift @@ -0,0 +1,21 @@ +import Foundation + +/// Defines the debouncing behavior for search input +public enum SearchDebounceStrategy: Equatable { + /// Smart debouncing: Skip debounce on first keystroke after a search completes, then debounce subsequent keystrokes. + /// Optimized for slow network searches where the first keystroke should show loading immediately. + /// - Parameter duration: The debounce duration in nanoseconds for subsequent keystrokes + case smart(duration: UInt64) + + /// Simple debouncing: Always debounce every keystroke by the specified duration. + /// Optionally delays showing loading indicators until a threshold is exceeded. + /// Optimized for fast local searches to prevent excessive queries and loading flicker. + /// - Parameters: + /// - duration: The debounce duration in nanoseconds + /// - loadingDelayThreshold: Optional threshold in nanoseconds before showing loading indicators. If nil, shows loading immediately. + case simple(duration: UInt64, loadingDelayThreshold: UInt64? = nil) + + /// Immediate: No debouncing, execute search on every keystroke. + /// Use when debouncing is not needed (e.g., non-search operations). + case immediate +} From 3637ba020493d9f1818b3199895beb439a43542c Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Wed, 19 Nov 2025 14:45:50 +0000 Subject: [PATCH 02/20] Add debounceStrategy property to fetch strategy protocols MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `debounceStrategy` property to both PointOfSalePurchasableItemFetchStrategy and PointOfSaleCouponFetchStrategy protocols with default implementations returning `.immediate`. This allows each fetch strategy implementation to declare its optimal debouncing behavior: - Remote search strategies can use `.smart()` for network latency - Local search strategies can use `.simple()` with loading delay thresholds - Default strategies use `.immediate` for no debouncing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../PointOfSaleCouponFetchStrategy.swift | 15 +++++++++++++++ ...ointOfSalePurchasableItemFetchStrategy.swift | 17 +++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/Modules/Sources/Yosemite/PointOfSale/Coupons/PointOfSaleCouponFetchStrategy.swift b/Modules/Sources/Yosemite/PointOfSale/Coupons/PointOfSaleCouponFetchStrategy.swift index 138b9b68702..0a32ddbedb8 100644 --- a/Modules/Sources/Yosemite/PointOfSale/Coupons/PointOfSaleCouponFetchStrategy.swift +++ b/Modules/Sources/Yosemite/PointOfSale/Coupons/PointOfSaleCouponFetchStrategy.swift @@ -6,6 +6,16 @@ import protocol Storage.StorageManagerType public protocol PointOfSaleCouponFetchStrategy { func fetchCoupons(pageNumber: Int) async throws -> PagedItems func fetchLocalCoupons() async throws -> [POSItem] + /// The debouncing strategy to use for search input. + /// Default is `.immediate` (no debouncing) for non-search strategies. + var debounceStrategy: SearchDebounceStrategy { get } +} + +public extension PointOfSaleCouponFetchStrategy { + /// Default implementation returns `.immediate` for strategies that don't need debouncing + var debounceStrategy: SearchDebounceStrategy { + .immediate + } } struct PointOfSaleDefaultCouponFetchStrategy: PointOfSaleCouponFetchStrategy { @@ -89,6 +99,11 @@ struct PointOfSaleSearchCouponFetchStrategy: PointOfSaleCouponFetchStrategy { self.analytics = analytics } + var debounceStrategy: SearchDebounceStrategy { + // Use smart debouncing for remote coupon search + .smart(duration: 500 * NSEC_PER_MSEC) + } + func fetchCoupons(pageNumber: Int) async throws -> PagedItems { let startTime = Date() try await couponStoreMethods.searchCoupons(siteID: siteID, diff --git a/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSalePurchasableItemFetchStrategy.swift b/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSalePurchasableItemFetchStrategy.swift index f1178dbe7af..26e312d87c2 100644 --- a/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSalePurchasableItemFetchStrategy.swift +++ b/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSalePurchasableItemFetchStrategy.swift @@ -5,6 +5,17 @@ import protocol Networking.ProductVariationsRemoteProtocol public protocol PointOfSalePurchasableItemFetchStrategy { func fetchProducts(pageNumber: Int) async throws -> PagedItems func fetchVariations(parentProductID: Int64, pageNumber: Int) async throws -> PagedItems + + /// The debouncing strategy to use for search input. + /// Default is `.immediate` (no debouncing) for non-search strategies. + var debounceStrategy: SearchDebounceStrategy { get } +} + +public extension PointOfSalePurchasableItemFetchStrategy { + /// Default implementation returns `.immediate` for strategies that don't need debouncing + var debounceStrategy: SearchDebounceStrategy { + .immediate + } } public struct PointOfSaleDefaultPurchasableItemFetchStrategy: PointOfSalePurchasableItemFetchStrategy { @@ -69,6 +80,12 @@ public struct PointOfSaleSearchPurchasableItemFetchStrategy: PointOfSalePurchasa self.analytics = analytics } + public var debounceStrategy: SearchDebounceStrategy { + // Use smart debouncing for remote search: don't debounce first keystroke to show loading immediately, + // then debounce subsequent keystrokes while search is ongoing + .smart(duration: 500 * NSEC_PER_MSEC) + } + public func fetchProducts(pageNumber: Int) async throws -> PagedItems { let startTime = Date() let pagedProducts = try await productsRemote.searchProductsForPointOfSale( From 6acb890b413d7c7e98877c1c140e9b6580710c68 Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Wed, 19 Nov 2025 14:46:05 +0000 Subject: [PATCH 03/20] Implement strategy-based debouncing in search UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the debouncing strategy pattern in the search UI layer: - Adds `debounceStrategy` property to POSSearchable protocol - Updates POSSearchField's onChange handler to execute different debouncing logic based on strategy: - `.smart`: Skip debounce on first keystroke, debounce subsequent - `.simple`: Always debounce, with optional delayed loading indicators - `.immediate`: No debouncing - Adds `currentDebounceStrategy` to item and coupon controllers to expose fetch strategy's debouncing behavior - Implements protocol conformance in POSProductSearchable, POSOrderListView, and preview helpers The `.simple` strategy with loading delay threshold prevents flicker on fast local searches by only showing loading indicators if the search exceeds the threshold duration. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../PointOfSaleCouponsController.swift | 5 ++ .../PointOfSaleItemsController.swift | 10 ++- .../Item Search/POSProductSearchable.swift | 5 ++ .../Item Search/POSSearchView.swift | 73 +++++++++++++++---- .../Orders/POSOrderListView.swift | 8 ++ .../PointOfSale/Utils/PreviewHelpers.swift | 4 + 6 files changed, 89 insertions(+), 16 deletions(-) diff --git a/Modules/Sources/PointOfSale/Controllers/PointOfSaleCouponsController.swift b/Modules/Sources/PointOfSale/Controllers/PointOfSaleCouponsController.swift index 388878c3427..725d3031d53 100644 --- a/Modules/Sources/PointOfSale/Controllers/PointOfSaleCouponsController.swift +++ b/Modules/Sources/PointOfSale/Controllers/PointOfSaleCouponsController.swift @@ -7,6 +7,7 @@ import protocol Yosemite.PointOfSaleCouponServiceProtocol import struct Yosemite.PointOfSaleCouponFetchStrategyFactory import protocol Yosemite.PointOfSaleCouponFetchStrategy import class Yosemite.AsyncPaginationTracker +import enum Yosemite.SearchDebounceStrategy protocol PointOfSaleCouponsControllerProtocol: PointOfSaleSearchingItemsControllerProtocol { /// Enables coupons in store settings @@ -57,6 +58,10 @@ protocol PointOfSaleCouponsControllerProtocol: PointOfSaleSearchingItemsControll setSearchingState() } + var currentDebounceStrategy: SearchDebounceStrategy { + fetchStrategy.debounceStrategy + } + @MainActor func loadNextItems(base: ItemListBaseItem) async { guard paginationTracker.hasNextPage else { diff --git a/Modules/Sources/PointOfSale/Controllers/PointOfSaleItemsController.swift b/Modules/Sources/PointOfSale/Controllers/PointOfSaleItemsController.swift index 62e842d7dea..05de35b5719 100644 --- a/Modules/Sources/PointOfSale/Controllers/PointOfSaleItemsController.swift +++ b/Modules/Sources/PointOfSale/Controllers/PointOfSaleItemsController.swift @@ -10,6 +10,7 @@ import struct Yosemite.POSVariableParentProduct import class Yosemite.Store import enum Yosemite.POSItemType import class Yosemite.AsyncPaginationTracker +import enum Yosemite.SearchDebounceStrategy protocol PointOfSaleItemsControllerProtocol { /// @@ -26,6 +27,8 @@ protocol PointOfSaleSearchingItemsControllerProtocol: PointOfSaleItemsController /// Searches for items func searchItems(searchTerm: String, baseItem: ItemListBaseItem) async func clearSearchItems(baseItem: ItemListBaseItem) + /// The debouncing strategy from the current fetch strategy + var currentDebounceStrategy: SearchDebounceStrategy { get } } @@ -69,7 +72,8 @@ protocol PointOfSaleSearchingItemsControllerProtocol: PointOfSaleItemsController fetchStrategy = itemFetchStrategyFactory.searchStrategy(searchTerm: searchTerm, analytics: POSItemFetchAnalytics(itemType: .product, analytics: analyticsProvider)) - setSearchingState(base: baseItem) + // Don't set searching state here - let the caller control when to show loading indicators + // via clearSearchResults(). This allows for delayed loading indicators for fast queries. await loadFirstPage(base: baseItem) } @@ -77,6 +81,10 @@ protocol PointOfSaleSearchingItemsControllerProtocol: PointOfSaleItemsController setSearchingState(base: baseItem) } + var currentDebounceStrategy: SearchDebounceStrategy { + fetchStrategy.debounceStrategy + } + @MainActor private func loadFirstPage(base: ItemListBaseItem) async { switch base { diff --git a/Modules/Sources/PointOfSale/Presentation/Item Search/POSProductSearchable.swift b/Modules/Sources/PointOfSale/Presentation/Item Search/POSProductSearchable.swift index a74053265dd..1bdd5af454c 100644 --- a/Modules/Sources/PointOfSale/Presentation/Item Search/POSProductSearchable.swift +++ b/Modules/Sources/PointOfSale/Presentation/Item Search/POSProductSearchable.swift @@ -2,6 +2,7 @@ import Foundation import enum Yosemite.POSItemType import protocol Yosemite.POSSearchHistoryProviding import enum Yosemite.POSItem +import enum Yosemite.SearchDebounceStrategy final class POSProductSearchable: POSSearchable { private let itemListType: ItemListType @@ -24,6 +25,10 @@ final class POSProductSearchable: POSSearchable { itemListType.itemType.searchFieldLabel } + var debounceStrategy: SearchDebounceStrategy { + itemsController.currentDebounceStrategy + } + func performSearch(term: String) async { await itemsController.searchItems(searchTerm: term, baseItem: .root) } diff --git a/Modules/Sources/PointOfSale/Presentation/Item Search/POSSearchView.swift b/Modules/Sources/PointOfSale/Presentation/Item Search/POSSearchView.swift index 974b7d09868..548521961af 100644 --- a/Modules/Sources/PointOfSale/Presentation/Item Search/POSSearchView.swift +++ b/Modules/Sources/PointOfSale/Presentation/Item Search/POSSearchView.swift @@ -1,12 +1,15 @@ import SwiftUI import enum Yosemite.POSItemType import enum Yosemite.POSItem +import enum Yosemite.SearchDebounceStrategy /// Protocol defining search capabilities for POS items protocol POSSearchable { var searchFieldPlaceholder: String { get } /// Recent search history for the current item type var searchHistory: [String] { get } + /// The debouncing strategy to use for search input + var debounceStrategy: SearchDebounceStrategy { get } /// Called when a search should be performed /// - Parameter term: The search term to use @@ -54,24 +57,64 @@ struct POSSearchField: View { .textInputAutocapitalization(.never) .focused($isSearchFieldFocused) .onChange(of: searchTerm) { oldValue, newValue in - // The debouncing logic is a little tricky, because the loading state is held in the controller. - // Arguably, we should use view state `isSearching` for this, so the UI is independent of the request timing. - - // As the user types, we don't want to send every keystroke to the remote, so we debounce the requests. - // However, we don't want to debounce the first keystroke of a new search, so that the loading - // state shows immediately and the UI feels responsive. - - // So, if the last search was finished, we don't debounce the first character. If it didn't - // finish i.e. it is still ongoing, we debounce the next keystrokes by 300ms. In either case, - // the ongoing search is redundant now there's a new search term, so we cancel it. - let shouldDebounceNextSearchRequest = !didFinishSearch + // Cancel any ongoing search searchTask?.cancel() searchTask = Task { - if shouldDebounceNextSearchRequest { - try? await Task.sleep(nanoseconds: 500 * NSEC_PER_MSEC) - } else { - searchable.clearSearchResults() + // Apply debouncing based on the strategy from the fetch strategy + switch searchable.debounceStrategy { + case .smart(let duration): + // Smart debouncing: Skip debounce on first keystroke after search completes, + // then debounce subsequent keystrokes + let shouldDebounce = !didFinishSearch + if shouldDebounce { + try? await Task.sleep(nanoseconds: duration) + } else { + searchable.clearSearchResults() + } + + case .simple(let duration, let loadingDelayThreshold): + // Simple debouncing: Always debounce + try? await Task.sleep(nanoseconds: duration) + + guard !Task.isCancelled else { return } + guard newValue.isNotEmpty else { + didFinishSearch = true + return + } + + didFinishSearch = false + + if let threshold = loadingDelayThreshold { + // Delay showing loading indicators to avoid flicker for fast queries + // Create a loading task that shows indicators after threshold + let loadingTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: threshold) + // Only show loading if not cancelled + if !Task.isCancelled { + searchable.clearSearchResults() + } + } + + // Perform the search + await searchable.performSearch(term: newValue) + + // Cancel loading task if search completed before threshold + loadingTask.cancel() + } else { + // No loading delay threshold - show loading immediately + searchable.clearSearchResults() + await searchable.performSearch(term: newValue) + } + + if !Task.isCancelled { + didFinishSearch = true + } + return + + case .immediate: + // No debouncing + break } guard !Task.isCancelled else { return } diff --git a/Modules/Sources/PointOfSale/Presentation/Orders/POSOrderListView.swift b/Modules/Sources/PointOfSale/Presentation/Orders/POSOrderListView.swift index 8d2f488db1f..8313dc24f64 100644 --- a/Modules/Sources/PointOfSale/Presentation/Orders/POSOrderListView.swift +++ b/Modules/Sources/PointOfSale/Presentation/Orders/POSOrderListView.swift @@ -2,6 +2,7 @@ import SwiftUI import struct WooFoundation.WooAnalyticsEvent import struct Yosemite.POSOrder import enum Yosemite.OrderPaymentMethod +import enum Yosemite.SearchDebounceStrategy struct POSOrderListView: View { @Binding var isSearching: Bool @@ -364,6 +365,13 @@ final class POSOrderSearchable: POSSearchable { [] } + var debounceStrategy: SearchDebounceStrategy { + // Use smart debouncing for order search to match original behavior: + // don't debounce first keystroke to show loading immediately, + // then debounce subsequent keystrokes while search is ongoing + .smart(duration: 500 * NSEC_PER_MSEC) + } + func performSearch(term: String) async { await ordersController.searchOrders(searchTerm: term) } diff --git a/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift b/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift index ead13993bec..14602a52f80 100644 --- a/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift +++ b/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift @@ -18,6 +18,7 @@ import struct Yosemite.POSProduct import struct Yosemite.POSProductVariation import protocol Yosemite.POSSearchHistoryProviding import enum Yosemite.POSItemType +import enum Yosemite.SearchDebounceStrategy import protocol Yosemite.PointOfSaleBarcodeScanServiceProtocol import enum Yosemite.PointOfSaleBarcodeScanError import Combine @@ -105,6 +106,7 @@ final class PointOfSalePreviewCouponsController: PointOfSaleCouponsControllerPro @Published var itemsViewState: ItemsViewState = ItemsViewState(containerState: .loading(), itemsStack: ItemsStackState(root: .loading([]), itemStates: [:])) + var currentDebounceStrategy: SearchDebounceStrategy { .immediate } func enableCoupons() async { } func loadItems(base: ItemListBaseItem) async { } func refreshItems(base: ItemListBaseItem) async { } @@ -118,6 +120,8 @@ final class PointOfSalePreviewItemsController: PointOfSaleSearchingItemsControll itemsStack: ItemsStackState(root: .loading([]), itemStates: [:])) + var currentDebounceStrategy: SearchDebounceStrategy { .immediate } + func loadItems(base: ItemListBaseItem) async { switch base { case .root: From 5dae392d9ac91d2e0efe60cabdd94b08667c6d4d Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Wed, 19 Nov 2025 14:46:58 +0000 Subject: [PATCH 04/20] Apply simple debouncing strategy to local product search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements `.simple(duration: 150ms, loadingDelayThreshold: 300ms)` for local GRDB product searches. This strategy: - Always debounces every keystroke by 150ms to prevent excessive queries - Delays showing loading indicators until 300ms threshold is exceeded - Prevents loading flicker for fast local searches (< 300ms) - Only shows loading indicators for slower searches (> 300ms) The combination of debouncing with delayed loading provides a responsive feel while avoiding visual flickering on fast local database queries. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- ...ointOfSaleLocalSearchPurchasableItemFetchStrategy.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleLocalSearchPurchasableItemFetchStrategy.swift b/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleLocalSearchPurchasableItemFetchStrategy.swift index 047e6286b9f..a384c659ed0 100644 --- a/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleLocalSearchPurchasableItemFetchStrategy.swift +++ b/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleLocalSearchPurchasableItemFetchStrategy.swift @@ -25,6 +25,13 @@ public struct PointOfSaleLocalSearchPurchasableItemFetchStrategy: PointOfSalePur self.pageSize = pageSize } + public var debounceStrategy: SearchDebounceStrategy { + // Use simple debouncing for local search: always debounce to prevent excessive queries + // even though local searches are fast. 100ms provides responsive feel while preventing + // queries on every keystroke. Delay loading indicators by 150ms to avoid flicker for fast queries. + .simple(duration: 150 * NSEC_PER_MSEC, loadingDelayThreshold: 300 * NSEC_PER_MSEC) + } + public func fetchProducts(pageNumber: Int) async throws -> PagedItems { let startTime = Date() From 753ca2423dd61ec43415321620d7429064f2b007 Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Wed, 19 Nov 2025 14:50:33 +0000 Subject: [PATCH 05/20] Add tests for SearchDebounceStrategy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds comprehensive test coverage for the debouncing strategy pattern: - Tests for SearchDebounceStrategy enum equality - Tests verifying each fetch strategy returns the correct debouncing behavior: - Local product search: `.simple` with loading delay threshold - Remote product search: `.smart` for network latency - Coupon search: `.smart` for network latency - Default strategies: `.immediate` for no debouncing Tests ensure the debouncing strategies are correctly configured across all search types for optimal UX. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../SearchDebounceStrategyTests.swift | 281 ++++++++++++++++++ 1 file changed, 281 insertions(+) create mode 100644 Modules/Tests/YosemiteTests/PointOfSale/SearchDebounceStrategyTests.swift diff --git a/Modules/Tests/YosemiteTests/PointOfSale/SearchDebounceStrategyTests.swift b/Modules/Tests/YosemiteTests/PointOfSale/SearchDebounceStrategyTests.swift new file mode 100644 index 00000000000..8190cef15da --- /dev/null +++ b/Modules/Tests/YosemiteTests/PointOfSale/SearchDebounceStrategyTests.swift @@ -0,0 +1,281 @@ +import Foundation +import Testing +import Networking +@testable import Storage +@testable import Yosemite + +@Suite("SearchDebounceStrategy Tests") +struct SearchDebounceStrategyTests { + + // MARK: - Equatable Tests + + @Test("Smart strategies with same duration are equal") + func test_smart_strategies_with_same_duration_are_equal() { + let strategy1: SearchDebounceStrategy = .smart(duration: 500 * NSEC_PER_MSEC) + let strategy2: SearchDebounceStrategy = .smart(duration: 500 * NSEC_PER_MSEC) + + #expect(strategy1 == strategy2) + } + + @Test("Smart strategies with different durations are not equal") + func test_smart_strategies_with_different_durations_are_not_equal() { + let strategy1: SearchDebounceStrategy = .smart(duration: 500 * NSEC_PER_MSEC) + let strategy2: SearchDebounceStrategy = .smart(duration: 300 * NSEC_PER_MSEC) + + #expect(strategy1 != strategy2) + } + + @Test("Simple strategies with same duration and no threshold are equal") + func test_simple_strategies_with_same_duration_and_no_threshold_are_equal() { + let strategy1: SearchDebounceStrategy = .simple(duration: 150 * NSEC_PER_MSEC) + let strategy2: SearchDebounceStrategy = .simple(duration: 150 * NSEC_PER_MSEC) + + #expect(strategy1 == strategy2) + } + + @Test("Simple strategies with same duration and same threshold are equal") + func test_simple_strategies_with_same_duration_and_threshold_are_equal() { + let strategy1: SearchDebounceStrategy = .simple(duration: 150 * NSEC_PER_MSEC, loadingDelayThreshold: 300 * NSEC_PER_MSEC) + let strategy2: SearchDebounceStrategy = .simple(duration: 150 * NSEC_PER_MSEC, loadingDelayThreshold: 300 * NSEC_PER_MSEC) + + #expect(strategy1 == strategy2) + } + + @Test("Simple strategies with different durations are not equal") + func test_simple_strategies_with_different_durations_are_not_equal() { + let strategy1: SearchDebounceStrategy = .simple(duration: 150 * NSEC_PER_MSEC, loadingDelayThreshold: 300 * NSEC_PER_MSEC) + let strategy2: SearchDebounceStrategy = .simple(duration: 200 * NSEC_PER_MSEC, loadingDelayThreshold: 300 * NSEC_PER_MSEC) + + #expect(strategy1 != strategy2) + } + + @Test("Simple strategies with different thresholds are not equal") + func test_simple_strategies_with_different_thresholds_are_not_equal() { + let strategy1: SearchDebounceStrategy = .simple(duration: 150 * NSEC_PER_MSEC, loadingDelayThreshold: 300 * NSEC_PER_MSEC) + let strategy2: SearchDebounceStrategy = .simple(duration: 150 * NSEC_PER_MSEC, loadingDelayThreshold: 400 * NSEC_PER_MSEC) + + #expect(strategy1 != strategy2) + } + + @Test("Immediate strategies are equal") + func test_immediate_strategies_are_equal() { + let strategy1: SearchDebounceStrategy = .immediate + let strategy2: SearchDebounceStrategy = .immediate + + #expect(strategy1 == strategy2) + } + + @Test("Different strategy types are not equal") + func test_different_strategy_types_are_not_equal() { + let smartStrategy: SearchDebounceStrategy = .smart(duration: 500 * NSEC_PER_MSEC) + let simpleStrategy: SearchDebounceStrategy = .simple(duration: 150 * NSEC_PER_MSEC) + let immediateStrategy: SearchDebounceStrategy = .immediate + + #expect(smartStrategy != simpleStrategy) + #expect(smartStrategy != immediateStrategy) + #expect(simpleStrategy != immediateStrategy) + } +} + +@Suite("Fetch Strategy Debouncing Tests") +struct FetchStrategyDebouncingTests { + private let siteID: Int64 = 123 + private let mockAnalytics = MockPOSItemFetchAnalyticsTracking() + + // MARK: - Local Search Strategy Tests + + @Test("Local search strategy returns simple debouncing with loading delay threshold") + func test_local_search_strategy_returns_simple_debouncing_with_threshold() async throws { + let grdbManager = try GRDBManager() + + // Initialize site + try await grdbManager.databaseConnection.write { db in + try PersistedSite(id: siteID).insert(db) + } + + let strategy = PointOfSaleLocalSearchPurchasableItemFetchStrategy( + siteID: siteID, + searchTerm: "test", + grdbManager: grdbManager, + variationsRemote: MockProductVariationsRemote(), + analytics: mockAnalytics + ) + + let expected: SearchDebounceStrategy = .simple(duration: 150 * NSEC_PER_MSEC, loadingDelayThreshold: 300 * NSEC_PER_MSEC) + #expect(strategy.debounceStrategy == expected) + } + + // MARK: - Remote Search Strategy Tests + + @Test("Remote search strategy returns smart debouncing") + func test_remote_search_strategy_returns_smart_debouncing() { + let strategy = PointOfSaleSearchPurchasableItemFetchStrategy( + siteID: siteID, + searchTerm: "test", + productsRemote: MockProductsRemote(), + variationsRemote: MockProductVariationsRemote(), + analytics: mockAnalytics + ) + + let expected: SearchDebounceStrategy = .smart(duration: 500 * NSEC_PER_MSEC) + #expect(strategy.debounceStrategy == expected) + } + + // MARK: - Default Strategy Tests + + @Test("Default purchasable item strategy returns immediate debouncing") + func test_default_purchasable_item_strategy_returns_immediate_debouncing() { + let strategy = PointOfSaleDefaultPurchasableItemFetchStrategy( + siteID: siteID, + productsRemote: MockProductsRemote(), + variationsRemote: MockProductVariationsRemote(), + analytics: mockAnalytics + ) + + #expect(strategy.debounceStrategy == .immediate) + } + + // MARK: - Coupon Strategy Tests + + @Test("Search coupon strategy returns smart debouncing") + func test_search_coupon_strategy_returns_smart_debouncing() { + let strategy = PointOfSaleSearchCouponFetchStrategy( + siteID: siteID, + currencySettings: .init(), + storage: MockStorageManager(), + couponStoreMethods: MockCouponStoreMethods(), + searchTerm: "test", + analytics: mockAnalytics + ) + + let expected: SearchDebounceStrategy = .smart(duration: 500 * NSEC_PER_MSEC) + #expect(strategy.debounceStrategy == expected) + } + + @Test("Default coupon strategy returns immediate debouncing") + func test_default_coupon_strategy_returns_immediate_debouncing() { + let strategy = PointOfSaleDefaultCouponFetchStrategy( + siteID: siteID, + currencySettings: .init(), + storage: MockStorageManager(), + couponStoreMethods: MockCouponStoreMethods() + ) + + #expect(strategy.debounceStrategy == .immediate) + } +} + +// MARK: - Mock Types + +private final class MockProductsRemote: ProductsRemoteProtocol { + func loadSimpleProducts(for siteID: Int64, pageNumber: Int, pageSize: Int) async throws -> (products: [Networking.Product], hasNextPage: Bool) { + ([], false) + } + + func loadAllProducts(for siteID: Int64, context: String?, pageNumber: Int, pageSize: Int, stockStatus: Networking.ProductStockStatus?, productStatus: Networking.ProductStatus?, productType: Networking.ProductType?, productCategory: Networking.ProductCategoryID?, orderBy: Networking.ProductsRemote.OrderKey, order: Networking.ProductsRemote.Order, excludedProductIDs: [Int64], includedProductIDs: [Int64]) async throws -> (products: [Networking.Product], hasNextPage: Bool) { + ([], false) + } + + func loadAllProducts(for siteID: Int64, context: String?, pageNumber: Int, pageSize: Int, stockStatus: Networking.ProductStockStatus?, productStatus: Networking.ProductStatus?, productType: Networking.ProductType?, productCategory: Networking.ProductCategoryID?, orderBy: Networking.ProductsRemote.OrderKey, order: Networking.ProductsRemote.Order, excludedProductIDs: [Int64]) async throws -> (products: [Networking.Product], hasNextPage: Bool) { + ([], false) + } + + func searchProducts(for siteID: Int64, keyword: String, pageNumber: Int, pageSize: Int, stockStatus: Networking.ProductStockStatus?, productStatus: Networking.ProductStatus?, productType: Networking.ProductType?, excludedProductIDs: [Int64]) async throws -> (products: [Networking.Product], hasNextPage: Bool) { + ([], false) + } + + func searchProducts(for siteID: Int64, keyword: String, pageNumber: Int, pageSize: Int, excludeTypes: [String]) async throws -> (products: [Networking.Product], hasNextPage: Bool) { + ([], false) + } + + func searchSku(for siteID: Int64, sku: String, pageNumber: Int, pageSize: Int) async throws -> (products: [Networking.Product], hasNextPage: Bool) { + ([], false) + } + + func loadProduct(for siteID: Int64, productID: Int64) async throws -> Networking.Product { + throw NSError(domain: "test", code: 0) + } + + func updateProducts(_ products: [Networking.Product]) async throws -> [Networking.Product] { + [] + } + + func deleteProduct(for siteID: Int64, productID: Int64, forceDelete: Bool) async throws -> Networking.Product { + throw NSError(domain: "test", code: 0) + } + + func retrieveProductShippingClass(for siteID: Int64, remoteID: Int64) async throws -> Networking.ProductShippingClass { + throw NSError(domain: "test", code: 0) + } + + func addProduct(product: Networking.Product) async throws -> Networking.Product { + throw NSError(domain: "test", code: 0) + } + + func searchProductsForPointOfSale(for siteID: Int64, keyword: String, pageNumber: Int, pageSize: Int, stockStatus: Networking.ProductStockStatus?) async throws -> Networking.PagedItems { + .init(items: [], hasMorePages: false, totalItems: nil) + } + + func loadProductsForPointOfSale(for siteID: Int64, pageNumber: Int, pageSize: Int, stockStatus: Networking.ProductStockStatus?) async throws -> Networking.PagedItems { + .init(items: [], hasMorePages: false, totalItems: nil) + } + + func loadPopularProductsForPointOfSale(for siteID: Int64) async throws -> [Networking.POSProduct] { + [] + } +} + +private final class MockProductVariationsRemote: ProductVariationsRemoteProtocol { + func loadVariationsForPointOfSale(for siteID: Int64, parentProductID: Int64, pageNumber: Int) async throws -> Networking.PagedItems { + .init(items: [], hasMorePages: false, totalItems: nil) + } + + func loadAllVariations(for siteID: Int64, productID: Int64, context: String?) async throws -> [Networking.ProductVariation] { + [] + } + + func loadVariation(for siteID: Int64, productID: Int64, variationID: Int64) async throws -> Networking.ProductVariation { + throw NSError(domain: "test", code: 0) + } + + func updateVariation(_ variation: Networking.ProductVariation) async throws -> Networking.ProductVariation { + throw NSError(domain: "test", code: 0) + } + + func createVariation(_ variation: Networking.ProductVariation) async throws -> Networking.ProductVariation { + throw NSError(domain: "test", code: 0) + } + + func deleteVariation(siteID: Int64, productID: Int64, variationID: Int64) async throws -> Networking.ProductVariation { + throw NSError(domain: "test", code: 0) + } +} + +private final class MockCouponStoreMethods: CouponStoreMethodsProtocol { + func synchronizeCoupons(siteID: Int64, pageNumber: Int, pageSize: Int) async throws -> Bool { + false + } + + func searchCoupons(siteID: Int64, keyword: String, pageNumber: Int, pageSize: Int) async throws { + } +} + +private final class MockPOSItemFetchAnalyticsTracking: POSItemFetchAnalyticsTracking { + var spyLocalSearchMilliseconds: Int? + var spyLocalSearchTotalItems: Int? + var spyRemoteSearchMilliseconds: Int? + var spyRemoteSearchTotalItems: Int? + + func trackSearchLocalResultsFetchComplete(millisecondsSinceRequestSent: Int, totalItems: Int) { + spyLocalSearchMilliseconds = millisecondsSinceRequestSent + spyLocalSearchTotalItems = totalItems + } + + func trackSearchRemoteResultsFetchComplete(millisecondsSinceRequestSent: Int, totalItems: Int) { + spyRemoteSearchMilliseconds = millisecondsSinceRequestSent + spyRemoteSearchTotalItems = totalItems + } + + func trackFetchComplete(millisecondsSinceRequestSent: Int, totalItems: Int) { + } +} From a81e1c7d77962ec9f54b25d878d2c76159c357b6 Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Thu, 20 Nov 2025 11:35:15 +0000 Subject: [PATCH 06/20] Fix line length lint violations in SearchDebounceStrategyTests --- .../SearchDebounceStrategyTests.swift | 58 ++++++++++++++++--- 1 file changed, 51 insertions(+), 7 deletions(-) diff --git a/Modules/Tests/YosemiteTests/PointOfSale/SearchDebounceStrategyTests.swift b/Modules/Tests/YosemiteTests/PointOfSale/SearchDebounceStrategyTests.swift index 8190cef15da..f56fff45b88 100644 --- a/Modules/Tests/YosemiteTests/PointOfSale/SearchDebounceStrategyTests.swift +++ b/Modules/Tests/YosemiteTests/PointOfSale/SearchDebounceStrategyTests.swift @@ -172,19 +172,51 @@ private final class MockProductsRemote: ProductsRemoteProtocol { ([], false) } - func loadAllProducts(for siteID: Int64, context: String?, pageNumber: Int, pageSize: Int, stockStatus: Networking.ProductStockStatus?, productStatus: Networking.ProductStatus?, productType: Networking.ProductType?, productCategory: Networking.ProductCategoryID?, orderBy: Networking.ProductsRemote.OrderKey, order: Networking.ProductsRemote.Order, excludedProductIDs: [Int64], includedProductIDs: [Int64]) async throws -> (products: [Networking.Product], hasNextPage: Bool) { + func loadAllProducts(for siteID: Int64, + context: String?, + pageNumber: Int, + pageSize: Int, + stockStatus: Networking.ProductStockStatus?, + productStatus: Networking.ProductStatus?, + productType: Networking.ProductType?, + productCategory: Networking.ProductCategoryID?, + orderBy: Networking.ProductsRemote.OrderKey, + order: Networking.ProductsRemote.Order, + excludedProductIDs: [Int64], + includedProductIDs: [Int64]) async throws -> (products: [Networking.Product], hasNextPage: Bool) { ([], false) } - func loadAllProducts(for siteID: Int64, context: String?, pageNumber: Int, pageSize: Int, stockStatus: Networking.ProductStockStatus?, productStatus: Networking.ProductStatus?, productType: Networking.ProductType?, productCategory: Networking.ProductCategoryID?, orderBy: Networking.ProductsRemote.OrderKey, order: Networking.ProductsRemote.Order, excludedProductIDs: [Int64]) async throws -> (products: [Networking.Product], hasNextPage: Bool) { + func loadAllProducts(for siteID: Int64, + context: String?, + pageNumber: Int, + pageSize: Int, + stockStatus: Networking.ProductStockStatus?, + productStatus: Networking.ProductStatus?, + productType: Networking.ProductType?, + productCategory: Networking.ProductCategoryID?, + orderBy: Networking.ProductsRemote.OrderKey, + order: Networking.ProductsRemote.Order, + excludedProductIDs: [Int64]) async throws -> (products: [Networking.Product], hasNextPage: Bool) { ([], false) } - func searchProducts(for siteID: Int64, keyword: String, pageNumber: Int, pageSize: Int, stockStatus: Networking.ProductStockStatus?, productStatus: Networking.ProductStatus?, productType: Networking.ProductType?, excludedProductIDs: [Int64]) async throws -> (products: [Networking.Product], hasNextPage: Bool) { + func searchProducts(for siteID: Int64, + keyword: String, + pageNumber: Int, + pageSize: Int, + stockStatus: Networking.ProductStockStatus?, + productStatus: Networking.ProductStatus?, + productType: Networking.ProductType?, + excludedProductIDs: [Int64]) async throws -> (products: [Networking.Product], hasNextPage: Bool) { ([], false) } - func searchProducts(for siteID: Int64, keyword: String, pageNumber: Int, pageSize: Int, excludeTypes: [String]) async throws -> (products: [Networking.Product], hasNextPage: Bool) { + func searchProducts(for siteID: Int64, + keyword: String, + pageNumber: Int, + pageSize: Int, + excludeTypes: [String]) async throws -> (products: [Networking.Product], hasNextPage: Bool) { ([], false) } @@ -212,11 +244,20 @@ private final class MockProductsRemote: ProductsRemoteProtocol { throw NSError(domain: "test", code: 0) } - func searchProductsForPointOfSale(for siteID: Int64, keyword: String, pageNumber: Int, pageSize: Int, stockStatus: Networking.ProductStockStatus?) async throws -> Networking.PagedItems { + func searchProductsForPointOfSale(for siteID: Int64, + keyword: String, + pageNumber: Int, + pageSize: Int, + stockStatus: Networking.ProductStockStatus?) async throws + -> Networking.PagedItems { .init(items: [], hasMorePages: false, totalItems: nil) } - func loadProductsForPointOfSale(for siteID: Int64, pageNumber: Int, pageSize: Int, stockStatus: Networking.ProductStockStatus?) async throws -> Networking.PagedItems { + func loadProductsForPointOfSale(for siteID: Int64, + pageNumber: Int, + pageSize: Int, + stockStatus: Networking.ProductStockStatus?) async throws + -> Networking.PagedItems { .init(items: [], hasMorePages: false, totalItems: nil) } @@ -226,7 +267,10 @@ private final class MockProductsRemote: ProductsRemoteProtocol { } private final class MockProductVariationsRemote: ProductVariationsRemoteProtocol { - func loadVariationsForPointOfSale(for siteID: Int64, parentProductID: Int64, pageNumber: Int) async throws -> Networking.PagedItems { + func loadVariationsForPointOfSale(for siteID: Int64, + parentProductID: Int64, + pageNumber: Int) async throws + -> Networking.PagedItems { .init(items: [], hasMorePages: false, totalItems: nil) } From b78f67473d2229da8ed03461ecf8f2f8db9e45da Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Thu, 20 Nov 2025 11:55:39 +0000 Subject: [PATCH 07/20] Fix SearchDebounceStrategyTests to use existing mocks --- .../SearchDebounceStrategyTests.swift | 174 +----------------- 1 file changed, 9 insertions(+), 165 deletions(-) diff --git a/Modules/Tests/YosemiteTests/PointOfSale/SearchDebounceStrategyTests.swift b/Modules/Tests/YosemiteTests/PointOfSale/SearchDebounceStrategyTests.swift index f56fff45b88..203be4cd128 100644 --- a/Modules/Tests/YosemiteTests/PointOfSale/SearchDebounceStrategyTests.swift +++ b/Modules/Tests/YosemiteTests/PointOfSale/SearchDebounceStrategyTests.swift @@ -81,12 +81,15 @@ struct SearchDebounceStrategyTests { struct FetchStrategyDebouncingTests { private let siteID: Int64 = 123 private let mockAnalytics = MockPOSItemFetchAnalyticsTracking() + private let mockProductsRemote = MockProductsRemote() + private let mockVariationsRemote = MockProductVariationsRemote() + private let mockCouponStoreMethods = MockCouponStoreMethods() // MARK: - Local Search Strategy Tests @Test("Local search strategy returns simple debouncing with loading delay threshold") func test_local_search_strategy_returns_simple_debouncing_with_threshold() async throws { - let grdbManager = try GRDBManager() + let grdbManager = try await GRDBManager() // Initialize site try await grdbManager.databaseConnection.write { db in @@ -97,7 +100,7 @@ struct FetchStrategyDebouncingTests { siteID: siteID, searchTerm: "test", grdbManager: grdbManager, - variationsRemote: MockProductVariationsRemote(), + variationsRemote: mockVariationsRemote, analytics: mockAnalytics ) @@ -112,8 +115,8 @@ struct FetchStrategyDebouncingTests { let strategy = PointOfSaleSearchPurchasableItemFetchStrategy( siteID: siteID, searchTerm: "test", - productsRemote: MockProductsRemote(), - variationsRemote: MockProductVariationsRemote(), + productsRemote: mockProductsRemote, + variationsRemote: mockVariationsRemote, analytics: mockAnalytics ) @@ -127,8 +130,8 @@ struct FetchStrategyDebouncingTests { func test_default_purchasable_item_strategy_returns_immediate_debouncing() { let strategy = PointOfSaleDefaultPurchasableItemFetchStrategy( siteID: siteID, - productsRemote: MockProductsRemote(), - variationsRemote: MockProductVariationsRemote(), + productsRemote: mockProductsRemote, + variationsRemote: mockVariationsRemote, analytics: mockAnalytics ) @@ -164,162 +167,3 @@ struct FetchStrategyDebouncingTests { #expect(strategy.debounceStrategy == .immediate) } } - -// MARK: - Mock Types - -private final class MockProductsRemote: ProductsRemoteProtocol { - func loadSimpleProducts(for siteID: Int64, pageNumber: Int, pageSize: Int) async throws -> (products: [Networking.Product], hasNextPage: Bool) { - ([], false) - } - - func loadAllProducts(for siteID: Int64, - context: String?, - pageNumber: Int, - pageSize: Int, - stockStatus: Networking.ProductStockStatus?, - productStatus: Networking.ProductStatus?, - productType: Networking.ProductType?, - productCategory: Networking.ProductCategoryID?, - orderBy: Networking.ProductsRemote.OrderKey, - order: Networking.ProductsRemote.Order, - excludedProductIDs: [Int64], - includedProductIDs: [Int64]) async throws -> (products: [Networking.Product], hasNextPage: Bool) { - ([], false) - } - - func loadAllProducts(for siteID: Int64, - context: String?, - pageNumber: Int, - pageSize: Int, - stockStatus: Networking.ProductStockStatus?, - productStatus: Networking.ProductStatus?, - productType: Networking.ProductType?, - productCategory: Networking.ProductCategoryID?, - orderBy: Networking.ProductsRemote.OrderKey, - order: Networking.ProductsRemote.Order, - excludedProductIDs: [Int64]) async throws -> (products: [Networking.Product], hasNextPage: Bool) { - ([], false) - } - - func searchProducts(for siteID: Int64, - keyword: String, - pageNumber: Int, - pageSize: Int, - stockStatus: Networking.ProductStockStatus?, - productStatus: Networking.ProductStatus?, - productType: Networking.ProductType?, - excludedProductIDs: [Int64]) async throws -> (products: [Networking.Product], hasNextPage: Bool) { - ([], false) - } - - func searchProducts(for siteID: Int64, - keyword: String, - pageNumber: Int, - pageSize: Int, - excludeTypes: [String]) async throws -> (products: [Networking.Product], hasNextPage: Bool) { - ([], false) - } - - func searchSku(for siteID: Int64, sku: String, pageNumber: Int, pageSize: Int) async throws -> (products: [Networking.Product], hasNextPage: Bool) { - ([], false) - } - - func loadProduct(for siteID: Int64, productID: Int64) async throws -> Networking.Product { - throw NSError(domain: "test", code: 0) - } - - func updateProducts(_ products: [Networking.Product]) async throws -> [Networking.Product] { - [] - } - - func deleteProduct(for siteID: Int64, productID: Int64, forceDelete: Bool) async throws -> Networking.Product { - throw NSError(domain: "test", code: 0) - } - - func retrieveProductShippingClass(for siteID: Int64, remoteID: Int64) async throws -> Networking.ProductShippingClass { - throw NSError(domain: "test", code: 0) - } - - func addProduct(product: Networking.Product) async throws -> Networking.Product { - throw NSError(domain: "test", code: 0) - } - - func searchProductsForPointOfSale(for siteID: Int64, - keyword: String, - pageNumber: Int, - pageSize: Int, - stockStatus: Networking.ProductStockStatus?) async throws - -> Networking.PagedItems { - .init(items: [], hasMorePages: false, totalItems: nil) - } - - func loadProductsForPointOfSale(for siteID: Int64, - pageNumber: Int, - pageSize: Int, - stockStatus: Networking.ProductStockStatus?) async throws - -> Networking.PagedItems { - .init(items: [], hasMorePages: false, totalItems: nil) - } - - func loadPopularProductsForPointOfSale(for siteID: Int64) async throws -> [Networking.POSProduct] { - [] - } -} - -private final class MockProductVariationsRemote: ProductVariationsRemoteProtocol { - func loadVariationsForPointOfSale(for siteID: Int64, - parentProductID: Int64, - pageNumber: Int) async throws - -> Networking.PagedItems { - .init(items: [], hasMorePages: false, totalItems: nil) - } - - func loadAllVariations(for siteID: Int64, productID: Int64, context: String?) async throws -> [Networking.ProductVariation] { - [] - } - - func loadVariation(for siteID: Int64, productID: Int64, variationID: Int64) async throws -> Networking.ProductVariation { - throw NSError(domain: "test", code: 0) - } - - func updateVariation(_ variation: Networking.ProductVariation) async throws -> Networking.ProductVariation { - throw NSError(domain: "test", code: 0) - } - - func createVariation(_ variation: Networking.ProductVariation) async throws -> Networking.ProductVariation { - throw NSError(domain: "test", code: 0) - } - - func deleteVariation(siteID: Int64, productID: Int64, variationID: Int64) async throws -> Networking.ProductVariation { - throw NSError(domain: "test", code: 0) - } -} - -private final class MockCouponStoreMethods: CouponStoreMethodsProtocol { - func synchronizeCoupons(siteID: Int64, pageNumber: Int, pageSize: Int) async throws -> Bool { - false - } - - func searchCoupons(siteID: Int64, keyword: String, pageNumber: Int, pageSize: Int) async throws { - } -} - -private final class MockPOSItemFetchAnalyticsTracking: POSItemFetchAnalyticsTracking { - var spyLocalSearchMilliseconds: Int? - var spyLocalSearchTotalItems: Int? - var spyRemoteSearchMilliseconds: Int? - var spyRemoteSearchTotalItems: Int? - - func trackSearchLocalResultsFetchComplete(millisecondsSinceRequestSent: Int, totalItems: Int) { - spyLocalSearchMilliseconds = millisecondsSinceRequestSent - spyLocalSearchTotalItems = totalItems - } - - func trackSearchRemoteResultsFetchComplete(millisecondsSinceRequestSent: Int, totalItems: Int) { - spyRemoteSearchMilliseconds = millisecondsSinceRequestSent - spyRemoteSearchTotalItems = totalItems - } - - func trackFetchComplete(millisecondsSinceRequestSent: Int, totalItems: Int) { - } -} From 60f51e1897bf21917705a2e46a53a37f71b61344 Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Thu, 20 Nov 2025 14:01:05 +0000 Subject: [PATCH 08/20] Add currentDebounceStrategy to mock controllers Update mocks to conform to PointOfSaleSearchingItemsControllerProtocol which now requires currentDebounceStrategy property. --- .../Mocks/MockPointOfSaleCouponsController.swift | 3 +++ .../MockPointOfSalePurchasableItemsSearchController.swift | 3 +++ 2 files changed, 6 insertions(+) diff --git a/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSaleCouponsController.swift b/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSaleCouponsController.swift index 0484ea0d91c..1eb3de65381 100644 --- a/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSaleCouponsController.swift +++ b/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSaleCouponsController.swift @@ -1,4 +1,5 @@ @testable import PointOfSale +import Yosemite final class MockPointOfSaleCouponsController: PointOfSaleCouponsControllerProtocol { var loadItemsCalled = false @@ -7,6 +8,8 @@ final class MockPointOfSaleCouponsController: PointOfSaleCouponsControllerProtoc var itemsViewState: ItemsViewState = .init(containerState: .content, itemsStack: .init(root: .empty, itemStates: [:])) + var currentDebounceStrategy: SearchDebounceStrategy = .immediate + func loadItems(base: ItemListBaseItem) async { loadItemsCalled = true loadItemsBase = base diff --git a/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSalePurchasableItemsSearchController.swift b/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSalePurchasableItemsSearchController.swift index 976a85379a5..ecb0436ea23 100644 --- a/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSalePurchasableItemsSearchController.swift +++ b/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSalePurchasableItemsSearchController.swift @@ -2,11 +2,14 @@ import Foundation import Combine @testable import PointOfSale import enum Yosemite.POSItem +import Yosemite final class MockPointOfSalePurchasableItemsSearchController: PointOfSaleSearchingItemsControllerProtocol { var itemsViewState: ItemsViewState = .init(containerState: .content, itemsStack: .init(root: .empty, itemStates: [:])) + var currentDebounceStrategy: SearchDebounceStrategy = .immediate + func searchItems(searchTerm: String, baseItem: ItemListBaseItem) async {} func loadItems(base: ItemListBaseItem) async { } From d37005d7a90447ed120bd883d5af7aa1ffa063c7 Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Thu, 20 Nov 2025 14:07:45 +0000 Subject: [PATCH 09/20] Fix lint --- .../MockPointOfSalePurchasableItemsSearchController.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSalePurchasableItemsSearchController.swift b/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSalePurchasableItemsSearchController.swift index ecb0436ea23..c8f6bf15a5b 100644 --- a/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSalePurchasableItemsSearchController.swift +++ b/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSalePurchasableItemsSearchController.swift @@ -2,7 +2,7 @@ import Foundation import Combine @testable import PointOfSale import enum Yosemite.POSItem -import Yosemite +import enum Yosemite.SearchDebounceStrategy final class MockPointOfSalePurchasableItemsSearchController: PointOfSaleSearchingItemsControllerProtocol { var itemsViewState: ItemsViewState = .init(containerState: .content, @@ -18,5 +18,5 @@ final class MockPointOfSalePurchasableItemsSearchController: PointOfSaleSearchin func loadNextItems(base: ItemListBaseItem) async { } - func clearSearchItems(baseItem: PointOfSale.ItemListBaseItem) { } + func clearSearchItems(baseItem: ItemListBaseItem) { } } From 0a668f15d48b984f7a0a8e8834cd5dedb963a9fd Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Thu, 20 Nov 2025 17:58:54 +0000 Subject: [PATCH 10/20] Add loadingDelayThreshold to smart debounce strategy Adds optional loading delay threshold parameter to smart debounce strategy. This enables delayed loading indicators for fast local searches to prevent flicker, while maintaining immediate loading indicators for remote searches. Changes: - Add optional loadingDelayThreshold parameter to SearchDebounceStrategy.smart case - Update POSSearchView to handle smart strategy with optional threshold: - Check for non-empty search term before showing loading (prevents loading on initial view) - Reset didFinishSearch to true when search view appears (ensures first search shows loading) - First keystroke without threshold: Show loading immediately (remote searches) - First keystroke with threshold: Delay loading until threshold or completion (local searches) - Subsequent keystrokes: Debounce request, loading already showing - Remote searches use .smart without threshold for immediate responsive feedback - Local searches use .simple with threshold to prevent flicker on fast queries - Update test expectations to match strategy configurations Fixes: - No loading indicators shown when opening search view with popular products - Loading indicators show immediately on first search keystroke for remote searches - Local searches avoid flicker by only showing loading if query takes longer than threshold --- .../Item Search/POSSearchView.swift | 62 ++++++++++++++++--- .../PointOfSaleCouponFetchStrategy.swift | 1 + ...ntOfSalePurchasableItemFetchStrategy.swift | 3 +- .../Items/SearchDebounceStrategy.swift | 7 ++- 4 files changed, 63 insertions(+), 10 deletions(-) diff --git a/Modules/Sources/PointOfSale/Presentation/Item Search/POSSearchView.swift b/Modules/Sources/PointOfSale/Presentation/Item Search/POSSearchView.swift index 548521961af..9b1e51800b2 100644 --- a/Modules/Sources/PointOfSale/Presentation/Item Search/POSSearchView.swift +++ b/Modules/Sources/PointOfSale/Presentation/Item Search/POSSearchView.swift @@ -63,16 +63,63 @@ struct POSSearchField: View { searchTask = Task { // Apply debouncing based on the strategy from the fetch strategy switch searchable.debounceStrategy { - case .smart(let duration): - // Smart debouncing: Skip debounce on first keystroke after search completes, - // then debounce subsequent keystrokes - let shouldDebounce = !didFinishSearch - if shouldDebounce { - try? await Task.sleep(nanoseconds: duration) + case .smart(let duration, let loadingDelayThreshold): + // Smart debouncing: Don't debounce first keystroke, but debounce subsequent keystrokes + // The loading indicator behavior depends on whether there's a threshold: + // - With threshold: Show loading after threshold if search hasn't completed (prevents flicker) + // - Without threshold: Show loading immediately (responsive feel) + + let shouldDebounceNextSearchRequest = !didFinishSearch + + // Early exit if search term is empty + guard newValue.isNotEmpty else { + didFinishSearch = true + return + } + + // Start loading indicator task if we have a threshold and this is first keystroke + let loadingTask: Task? + if !shouldDebounceNextSearchRequest { + // First keystroke - handle loading indicators + if let threshold = loadingDelayThreshold { + // With threshold: delay showing loading to prevent flicker for fast searches + loadingTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: threshold) + if !Task.isCancelled { + searchable.clearSearchResults() + } + } + } else { + // No threshold - show loading immediately for responsive feel + searchable.clearSearchResults() + loadingTask = nil + } } else { - searchable.clearSearchResults() + // Subsequent keystrokes - loading already showing from previous search + loadingTask = nil } + if shouldDebounceNextSearchRequest { + try? await Task.sleep(nanoseconds: duration) + } + + // Now perform the search (common code for both and subsequent keystrokes) + guard !Task.isCancelled else { + loadingTask?.cancel() + return + } + + didFinishSearch = false + await searchable.performSearch(term: newValue) + + // Cancel loading task if search completed (only relevant for first keystroke with threshold) + loadingTask?.cancel() + + if !Task.isCancelled { + didFinishSearch = true + } + return + case .simple(let duration, let loadingDelayThreshold): // Simple debouncing: Always debounce try? await Task.sleep(nanoseconds: duration) @@ -139,6 +186,7 @@ struct POSSearchField: View { } .onAppear { isSearchFieldFocused = true + didFinishSearch = true // Reset state when search view appears } } } diff --git a/Modules/Sources/Yosemite/PointOfSale/Coupons/PointOfSaleCouponFetchStrategy.swift b/Modules/Sources/Yosemite/PointOfSale/Coupons/PointOfSaleCouponFetchStrategy.swift index 0a32ddbedb8..f975a7b720e 100644 --- a/Modules/Sources/Yosemite/PointOfSale/Coupons/PointOfSaleCouponFetchStrategy.swift +++ b/Modules/Sources/Yosemite/PointOfSale/Coupons/PointOfSaleCouponFetchStrategy.swift @@ -101,6 +101,7 @@ struct PointOfSaleSearchCouponFetchStrategy: PointOfSaleCouponFetchStrategy { var debounceStrategy: SearchDebounceStrategy { // Use smart debouncing for remote coupon search + // No loading delay threshold - show loading immediately for responsive feel .smart(duration: 500 * NSEC_PER_MSEC) } diff --git a/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSalePurchasableItemFetchStrategy.swift b/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSalePurchasableItemFetchStrategy.swift index 26e312d87c2..3d7990d77dc 100644 --- a/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSalePurchasableItemFetchStrategy.swift +++ b/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSalePurchasableItemFetchStrategy.swift @@ -82,7 +82,8 @@ public struct PointOfSaleSearchPurchasableItemFetchStrategy: PointOfSalePurchasa public var debounceStrategy: SearchDebounceStrategy { // Use smart debouncing for remote search: don't debounce first keystroke to show loading immediately, - // then debounce subsequent keystrokes while search is ongoing + // then debounce subsequent keystrokes while search is ongoing. + // No loading delay threshold - show loading immediately for responsive feel. .smart(duration: 500 * NSEC_PER_MSEC) } diff --git a/Modules/Sources/Yosemite/PointOfSale/Items/SearchDebounceStrategy.swift b/Modules/Sources/Yosemite/PointOfSale/Items/SearchDebounceStrategy.swift index c6c1846360a..b9fa72deef3 100644 --- a/Modules/Sources/Yosemite/PointOfSale/Items/SearchDebounceStrategy.swift +++ b/Modules/Sources/Yosemite/PointOfSale/Items/SearchDebounceStrategy.swift @@ -3,9 +3,12 @@ import Foundation /// Defines the debouncing behavior for search input public enum SearchDebounceStrategy: Equatable { /// Smart debouncing: Skip debounce on first keystroke after a search completes, then debounce subsequent keystrokes. + /// Optionally delays showing loading indicators until a threshold is exceeded. /// Optimized for slow network searches where the first keystroke should show loading immediately. - /// - Parameter duration: The debounce duration in nanoseconds for subsequent keystrokes - case smart(duration: UInt64) + /// - Parameters: + /// - duration: The debounce duration in nanoseconds for subsequent keystrokes + /// - loadingDelayThreshold: Optional threshold in nanoseconds before showing loading indicators. If nil, shows loading immediately. + case smart(duration: UInt64, loadingDelayThreshold: UInt64? = nil) /// Simple debouncing: Always debounce every keystroke by the specified duration. /// Optionally delays showing loading indicators until a threshold is exceeded. From 9656089c5608d548bb93a31b0230b4dfff9e16e1 Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Thu, 20 Nov 2025 16:18:44 +0000 Subject: [PATCH 11/20] Fix loading indicators not showing on first remote search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The issue was that the debounce strategy was determined by the current fetch strategy, but the fetch strategy didn't change from "popular products" to "search" until performSearch() was called. This meant the first keystroke used the `.immediate` strategy from popular products, which set `didFinishSearch=false` but didn't call `clearSearchResults()` to show loading. Solution: Added `searchDebounceStrategy` property to POSSearchable protocol that returns the debounce strategy that will be used for searches, regardless of the current fetch mode. The onChange handler now captures this strategy synchronously before creating the Task, ensuring we use the search strategy (.smart) from the very first keystroke. Changes: - Added `searchDebounceStrategy` to POSSearchable protocol - Updated POSSearchView to use searchDebounceStrategy for non-empty searches - Implemented searchDebounceStrategy in controllers by creating temporary search strategy to query its debounce settings - Updated all mocks and preview helpers to conform to new protocol 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Controllers/PointOfSaleCouponsController.swift | 8 ++++++++ .../Controllers/PointOfSaleItemsController.swift | 11 +++++++++++ .../Item Search/POSProductSearchable.swift | 4 ++++ .../Presentation/Item Search/POSSearchView.swift | 12 +++++++++--- .../Presentation/Orders/POSOrderListView.swift | 5 +++++ .../Sources/PointOfSale/Utils/PreviewHelpers.swift | 2 ++ .../Mocks/MockPointOfSaleCouponsController.swift | 1 + ...PointOfSalePurchasableItemsSearchController.swift | 1 + 8 files changed, 41 insertions(+), 3 deletions(-) diff --git a/Modules/Sources/PointOfSale/Controllers/PointOfSaleCouponsController.swift b/Modules/Sources/PointOfSale/Controllers/PointOfSaleCouponsController.swift index 725d3031d53..024f1e7847c 100644 --- a/Modules/Sources/PointOfSale/Controllers/PointOfSaleCouponsController.swift +++ b/Modules/Sources/PointOfSale/Controllers/PointOfSaleCouponsController.swift @@ -62,6 +62,14 @@ protocol PointOfSaleCouponsControllerProtocol: PointOfSaleSearchingItemsControll fetchStrategy.debounceStrategy } + var searchDebounceStrategy: SearchDebounceStrategy { + // Return the debounce strategy that would be used for a search + let searchStrategy = fetchStrategyFactory.searchStrategy(searchTerm: "", + analytics: POSItemFetchAnalytics(itemType: .coupon, + analytics: analyticsProvider)) + return searchStrategy.debounceStrategy + } + @MainActor func loadNextItems(base: ItemListBaseItem) async { guard paginationTracker.hasNextPage else { diff --git a/Modules/Sources/PointOfSale/Controllers/PointOfSaleItemsController.swift b/Modules/Sources/PointOfSale/Controllers/PointOfSaleItemsController.swift index 05de35b5719..c71abf4f2ae 100644 --- a/Modules/Sources/PointOfSale/Controllers/PointOfSaleItemsController.swift +++ b/Modules/Sources/PointOfSale/Controllers/PointOfSaleItemsController.swift @@ -29,6 +29,8 @@ protocol PointOfSaleSearchingItemsControllerProtocol: PointOfSaleItemsController func clearSearchItems(baseItem: ItemListBaseItem) /// The debouncing strategy from the current fetch strategy var currentDebounceStrategy: SearchDebounceStrategy { get } + /// The debouncing strategy that will be used when performing a search + var searchDebounceStrategy: SearchDebounceStrategy { get } } @@ -85,6 +87,15 @@ protocol PointOfSaleSearchingItemsControllerProtocol: PointOfSaleItemsController fetchStrategy.debounceStrategy } + var searchDebounceStrategy: SearchDebounceStrategy { + // Return the debounce strategy that would be used for a search + // We create a temporary search strategy to get its debounce settings + let searchStrategy = itemFetchStrategyFactory.searchStrategy(searchTerm: "", + analytics: POSItemFetchAnalytics(itemType: .product, + analytics: analyticsProvider)) + return searchStrategy.debounceStrategy + } + @MainActor private func loadFirstPage(base: ItemListBaseItem) async { switch base { diff --git a/Modules/Sources/PointOfSale/Presentation/Item Search/POSProductSearchable.swift b/Modules/Sources/PointOfSale/Presentation/Item Search/POSProductSearchable.swift index 1bdd5af454c..e129c68a732 100644 --- a/Modules/Sources/PointOfSale/Presentation/Item Search/POSProductSearchable.swift +++ b/Modules/Sources/PointOfSale/Presentation/Item Search/POSProductSearchable.swift @@ -29,6 +29,10 @@ final class POSProductSearchable: POSSearchable { itemsController.currentDebounceStrategy } + var searchDebounceStrategy: SearchDebounceStrategy { + itemsController.searchDebounceStrategy + } + func performSearch(term: String) async { await itemsController.searchItems(searchTerm: term, baseItem: .root) } diff --git a/Modules/Sources/PointOfSale/Presentation/Item Search/POSSearchView.swift b/Modules/Sources/PointOfSale/Presentation/Item Search/POSSearchView.swift index 9b1e51800b2..ae21d71f7e9 100644 --- a/Modules/Sources/PointOfSale/Presentation/Item Search/POSSearchView.swift +++ b/Modules/Sources/PointOfSale/Presentation/Item Search/POSSearchView.swift @@ -10,6 +10,8 @@ protocol POSSearchable { var searchHistory: [String] { get } /// The debouncing strategy to use for search input var debounceStrategy: SearchDebounceStrategy { get } + /// The debouncing strategy that will be used when performing a search (may differ from current strategy) + var searchDebounceStrategy: SearchDebounceStrategy { get } /// Called when a search should be performed /// - Parameter term: The search term to use @@ -60,9 +62,14 @@ struct POSSearchField: View { // Cancel any ongoing search searchTask?.cancel() + // Capture the debounce strategy synchronously BEFORE creating the task. + // Use searchDebounceStrategy for non-empty search terms (actual searches), + // and debounceStrategy for empty terms (returning to popular products). + let debounceStrategy = newValue.isNotEmpty ? searchable.searchDebounceStrategy : searchable.debounceStrategy + searchTask = Task { - // Apply debouncing based on the strategy from the fetch strategy - switch searchable.debounceStrategy { + // Apply debouncing based on the strategy captured at the start + switch debounceStrategy { case .smart(let duration, let loadingDelayThreshold): // Smart debouncing: Don't debounce first keystroke, but debounce subsequent keystrokes // The loading indicator behavior depends on whether there's a threshold: @@ -186,7 +193,6 @@ struct POSSearchField: View { } .onAppear { isSearchFieldFocused = true - didFinishSearch = true // Reset state when search view appears } } } diff --git a/Modules/Sources/PointOfSale/Presentation/Orders/POSOrderListView.swift b/Modules/Sources/PointOfSale/Presentation/Orders/POSOrderListView.swift index 8313dc24f64..653e049f272 100644 --- a/Modules/Sources/PointOfSale/Presentation/Orders/POSOrderListView.swift +++ b/Modules/Sources/PointOfSale/Presentation/Orders/POSOrderListView.swift @@ -372,6 +372,11 @@ final class POSOrderSearchable: POSSearchable { .smart(duration: 500 * NSEC_PER_MSEC) } + var searchDebounceStrategy: SearchDebounceStrategy { + // Orders use the same strategy for both modes + debounceStrategy + } + func performSearch(term: String) async { await ordersController.searchOrders(searchTerm: term) } diff --git a/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift b/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift index 14602a52f80..25d843949a1 100644 --- a/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift +++ b/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift @@ -107,6 +107,7 @@ final class PointOfSalePreviewCouponsController: PointOfSaleCouponsControllerPro itemsStack: ItemsStackState(root: .loading([]), itemStates: [:])) var currentDebounceStrategy: SearchDebounceStrategy { .immediate } + var searchDebounceStrategy: SearchDebounceStrategy { .smart(duration: 500 * NSEC_PER_MSEC) } func enableCoupons() async { } func loadItems(base: ItemListBaseItem) async { } func refreshItems(base: ItemListBaseItem) async { } @@ -121,6 +122,7 @@ final class PointOfSalePreviewItemsController: PointOfSaleSearchingItemsControll itemStates: [:])) var currentDebounceStrategy: SearchDebounceStrategy { .immediate } + var searchDebounceStrategy: SearchDebounceStrategy { .smart(duration: 500 * NSEC_PER_MSEC) } func loadItems(base: ItemListBaseItem) async { switch base { diff --git a/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSaleCouponsController.swift b/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSaleCouponsController.swift index 1eb3de65381..0bbd274aae9 100644 --- a/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSaleCouponsController.swift +++ b/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSaleCouponsController.swift @@ -9,6 +9,7 @@ final class MockPointOfSaleCouponsController: PointOfSaleCouponsControllerProtoc itemsStack: .init(root: .empty, itemStates: [:])) var currentDebounceStrategy: SearchDebounceStrategy = .immediate + var searchDebounceStrategy: SearchDebounceStrategy = .smart(duration: 500 * NSEC_PER_MSEC) func loadItems(base: ItemListBaseItem) async { loadItemsCalled = true diff --git a/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSalePurchasableItemsSearchController.swift b/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSalePurchasableItemsSearchController.swift index c8f6bf15a5b..b9ef4251b0d 100644 --- a/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSalePurchasableItemsSearchController.swift +++ b/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSalePurchasableItemsSearchController.swift @@ -9,6 +9,7 @@ final class MockPointOfSalePurchasableItemsSearchController: PointOfSaleSearchin itemsStack: .init(root: .empty, itemStates: [:])) var currentDebounceStrategy: SearchDebounceStrategy = .immediate + var searchDebounceStrategy: SearchDebounceStrategy = .smart(duration: 500 * NSEC_PER_MSEC) func searchItems(searchTerm: String, baseItem: ItemListBaseItem) async {} From de5a6256185e6afa6f1abf403264caf9974090d9 Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Thu, 20 Nov 2025 18:03:14 +0000 Subject: [PATCH 12/20] Remove unnecessary comment --- .../PointOfSale/Controllers/PointOfSaleItemsController.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Modules/Sources/PointOfSale/Controllers/PointOfSaleItemsController.swift b/Modules/Sources/PointOfSale/Controllers/PointOfSaleItemsController.swift index c71abf4f2ae..bc0e3e6f3c6 100644 --- a/Modules/Sources/PointOfSale/Controllers/PointOfSaleItemsController.swift +++ b/Modules/Sources/PointOfSale/Controllers/PointOfSaleItemsController.swift @@ -74,8 +74,6 @@ protocol PointOfSaleSearchingItemsControllerProtocol: PointOfSaleItemsController fetchStrategy = itemFetchStrategyFactory.searchStrategy(searchTerm: searchTerm, analytics: POSItemFetchAnalytics(itemType: .product, analytics: analyticsProvider)) - // Don't set searching state here - let the caller control when to show loading indicators - // via clearSearchResults(). This allows for delayed loading indicators for fast queries. await loadFirstPage(base: baseItem) } From 42335aff33ee5ee6ab1556a324cf65fd768c7549 Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Thu, 20 Nov 2025 18:16:53 +0000 Subject: [PATCH 13/20] Fix test build --- .../Mocks/MockPointOfSaleCouponsController.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSaleCouponsController.swift b/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSaleCouponsController.swift index 0bbd274aae9..b55585d2ddc 100644 --- a/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSaleCouponsController.swift +++ b/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSaleCouponsController.swift @@ -1,5 +1,6 @@ @testable import PointOfSale import Yosemite +import Foundation final class MockPointOfSaleCouponsController: PointOfSaleCouponsControllerProtocol { var loadItemsCalled = false From 5e4e396cd06b9934245de02e08143fcaef750d7d Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Thu, 20 Nov 2025 18:31:28 +0000 Subject: [PATCH 14/20] Fix periphery issues --- ...ntOfSaleLocalSearchPurchasableItemFetchStrategy.swift | 9 +++++---- .../Items/PointOfSalePurchasableItemFetchStrategy.swift | 1 + 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleLocalSearchPurchasableItemFetchStrategy.swift b/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleLocalSearchPurchasableItemFetchStrategy.swift index a384c659ed0..58b32470436 100644 --- a/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleLocalSearchPurchasableItemFetchStrategy.swift +++ b/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleLocalSearchPurchasableItemFetchStrategy.swift @@ -3,10 +3,11 @@ import protocol Storage.GRDBManagerProtocol import protocol Networking.ProductVariationsRemoteProtocol /// Fetch strategy for searching products in the local GRDB catalog using SQL LIKE queries -public struct PointOfSaleLocalSearchPurchasableItemFetchStrategy: PointOfSalePurchasableItemFetchStrategy { +struct PointOfSaleLocalSearchPurchasableItemFetchStrategy: PointOfSalePurchasableItemFetchStrategy { private let siteID: Int64 private let searchTerm: String private let grdbManager: GRDBManagerProtocol + // periphery:ignore - Reserved for future variation fetching from remote when not in local catalog private let variationsRemote: ProductVariationsRemoteProtocol private let analytics: POSItemFetchAnalyticsTracking private let pageSize: Int @@ -25,14 +26,14 @@ public struct PointOfSaleLocalSearchPurchasableItemFetchStrategy: PointOfSalePur self.pageSize = pageSize } - public var debounceStrategy: SearchDebounceStrategy { + var debounceStrategy: SearchDebounceStrategy { // Use simple debouncing for local search: always debounce to prevent excessive queries // even though local searches are fast. 100ms provides responsive feel while preventing // queries on every keystroke. Delay loading indicators by 150ms to avoid flicker for fast queries. .simple(duration: 150 * NSEC_PER_MSEC, loadingDelayThreshold: 300 * NSEC_PER_MSEC) } - public func fetchProducts(pageNumber: Int) async throws -> PagedItems { + func fetchProducts(pageNumber: Int) async throws -> PagedItems { let startTime = Date() // Get total count and persisted products in one transaction @@ -69,7 +70,7 @@ public struct PointOfSaleLocalSearchPurchasableItemFetchStrategy: PointOfSalePur totalItems: totalCount) } - public func fetchVariations(parentProductID: Int64, pageNumber: Int) async throws -> PagedItems { + func fetchVariations(parentProductID: Int64, pageNumber: Int) async throws -> PagedItems { // Get total count and persisted variations in one transaction let (persistedVariations, totalCount) = try await grdbManager.databaseConnection.read { db in let totalCount = try PersistedProductVariation diff --git a/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSalePurchasableItemFetchStrategy.swift b/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSalePurchasableItemFetchStrategy.swift index 3d7990d77dc..859c6471832 100644 --- a/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSalePurchasableItemFetchStrategy.swift +++ b/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSalePurchasableItemFetchStrategy.swift @@ -80,6 +80,7 @@ public struct PointOfSaleSearchPurchasableItemFetchStrategy: PointOfSalePurchasa self.analytics = analytics } + // periphery:ignore - Protocol requirement, used via protocol public var debounceStrategy: SearchDebounceStrategy { // Use smart debouncing for remote search: don't debounce first keystroke to show loading immediately, // then debounce subsequent keystrokes while search is ongoing. From 2dc3ff6dadef1632fb1d237cbd754b66c08157b3 Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Thu, 20 Nov 2025 18:37:29 +0000 Subject: [PATCH 15/20] Rename debounceStrategy to currentDebounceStrategy for clarity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The POSSearchable protocol had two confusingly named properties: - debounceStrategy: The currently active strategy - searchDebounceStrategy: The strategy that will be used for searches Renamed debounceStrategy → currentDebounceStrategy to make the distinction clear: currentDebounceStrategy reflects what's active right now, while searchDebounceStrategy reflects what will be used when searching. This improves code readability without changing any behavior. --- .../Presentation/Item Search/POSProductSearchable.swift | 2 +- .../Presentation/Item Search/POSSearchView.swift | 8 ++++---- .../Presentation/Orders/POSOrderListView.swift | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Modules/Sources/PointOfSale/Presentation/Item Search/POSProductSearchable.swift b/Modules/Sources/PointOfSale/Presentation/Item Search/POSProductSearchable.swift index e129c68a732..1869e7849de 100644 --- a/Modules/Sources/PointOfSale/Presentation/Item Search/POSProductSearchable.swift +++ b/Modules/Sources/PointOfSale/Presentation/Item Search/POSProductSearchable.swift @@ -25,7 +25,7 @@ final class POSProductSearchable: POSSearchable { itemListType.itemType.searchFieldLabel } - var debounceStrategy: SearchDebounceStrategy { + var currentDebounceStrategy: SearchDebounceStrategy { itemsController.currentDebounceStrategy } diff --git a/Modules/Sources/PointOfSale/Presentation/Item Search/POSSearchView.swift b/Modules/Sources/PointOfSale/Presentation/Item Search/POSSearchView.swift index ae21d71f7e9..9a217a6d0fc 100644 --- a/Modules/Sources/PointOfSale/Presentation/Item Search/POSSearchView.swift +++ b/Modules/Sources/PointOfSale/Presentation/Item Search/POSSearchView.swift @@ -8,8 +8,8 @@ protocol POSSearchable { var searchFieldPlaceholder: String { get } /// Recent search history for the current item type var searchHistory: [String] { get } - /// The debouncing strategy to use for search input - var debounceStrategy: SearchDebounceStrategy { get } + /// The debouncing strategy currently active based on the controller's current state + var currentDebounceStrategy: SearchDebounceStrategy { get } /// The debouncing strategy that will be used when performing a search (may differ from current strategy) var searchDebounceStrategy: SearchDebounceStrategy { get } @@ -64,8 +64,8 @@ struct POSSearchField: View { // Capture the debounce strategy synchronously BEFORE creating the task. // Use searchDebounceStrategy for non-empty search terms (actual searches), - // and debounceStrategy for empty terms (returning to popular products). - let debounceStrategy = newValue.isNotEmpty ? searchable.searchDebounceStrategy : searchable.debounceStrategy + // and currentDebounceStrategy for empty terms (returning to popular products). + let debounceStrategy = newValue.isNotEmpty ? searchable.searchDebounceStrategy : searchable.currentDebounceStrategy searchTask = Task { // Apply debouncing based on the strategy captured at the start diff --git a/Modules/Sources/PointOfSale/Presentation/Orders/POSOrderListView.swift b/Modules/Sources/PointOfSale/Presentation/Orders/POSOrderListView.swift index 653e049f272..f2a2ac270c0 100644 --- a/Modules/Sources/PointOfSale/Presentation/Orders/POSOrderListView.swift +++ b/Modules/Sources/PointOfSale/Presentation/Orders/POSOrderListView.swift @@ -365,7 +365,7 @@ final class POSOrderSearchable: POSSearchable { [] } - var debounceStrategy: SearchDebounceStrategy { + var currentDebounceStrategy: SearchDebounceStrategy { // Use smart debouncing for order search to match original behavior: // don't debounce first keystroke to show loading immediately, // then debounce subsequent keystrokes while search is ongoing @@ -374,7 +374,7 @@ final class POSOrderSearchable: POSSearchable { var searchDebounceStrategy: SearchDebounceStrategy { // Orders use the same strategy for both modes - debounceStrategy + currentDebounceStrategy } func performSearch(term: String) async { From 992afafec80ce5cd27f51a09e9f6103a2b26024f Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Fri, 21 Nov 2025 14:12:56 +0000 Subject: [PATCH 16/20] Escape the escape! --- Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift b/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift index b1db2239528..a0f86f86046 100644 --- a/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift +++ b/Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift @@ -156,6 +156,7 @@ public extension PersistedProduct { /// - Returns: An escaped pattern safe for use in LIKE queries private static func escapeSQLLikePattern(_ pattern: String) -> String { pattern + .replacingOccurrences(of: "\\", with: "\\\\") .replacingOccurrences(of: "%", with: "\\%") .replacingOccurrences(of: "_", with: "\\_") } From 37b4e983d293f507ec66eae11fbde79251576162 Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Fri, 21 Nov 2025 14:43:58 +0000 Subject: [PATCH 17/20] =?UTF-8?q?Don=E2=80=99t=20show=20intermittent=20can?= =?UTF-8?q?cellation=20errors=20in=20search?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Yosemite/PointOfSale/Items/PointOfSaleItemService.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleItemService.swift b/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleItemService.swift index 06a7a55aecd..186db3c6202 100644 --- a/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleItemService.swift +++ b/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSaleItemService.swift @@ -36,7 +36,7 @@ public final class PointOfSaleItemService: PointOfSaleItemServiceProtocol { return .init(items: itemMapper.mapProductsToPOSItems(products: products), hasMorePages: pagedProducts.hasMorePages, totalItems: pagedProducts.totalItems) - } catch AFError.explicitlyCancelled { + } catch AFError.explicitlyCancelled, is CancellationError { throw PointOfSaleItemServiceError.requestCancelled } } @@ -55,7 +55,7 @@ public final class PointOfSaleItemService: PointOfSaleItemServiceProtocol { hasMorePages: pagedVariations.hasMorePages, totalItems: pagedVariations.totalItems ) - } catch AFError.explicitlyCancelled { + } catch AFError.explicitlyCancelled, is CancellationError { throw PointOfSaleItemServiceError.requestCancelled } } From 6d45ae60dfaf54d8889e939d8b146fb371d78ddc Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Mon, 24 Nov 2025 17:10:07 +0000 Subject: [PATCH 18/20] Remove unnecessary `await` call Co-authored-by: Gabriel Maldonado --- .../YosemiteTests/PointOfSale/SearchDebounceStrategyTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/Tests/YosemiteTests/PointOfSale/SearchDebounceStrategyTests.swift b/Modules/Tests/YosemiteTests/PointOfSale/SearchDebounceStrategyTests.swift index 203be4cd128..13392276abd 100644 --- a/Modules/Tests/YosemiteTests/PointOfSale/SearchDebounceStrategyTests.swift +++ b/Modules/Tests/YosemiteTests/PointOfSale/SearchDebounceStrategyTests.swift @@ -89,7 +89,7 @@ struct FetchStrategyDebouncingTests { @Test("Local search strategy returns simple debouncing with loading delay threshold") func test_local_search_strategy_returns_simple_debouncing_with_threshold() async throws { - let grdbManager = try await GRDBManager() + let grdbManager = try GRDBManager() // Initialize site try await grdbManager.databaseConnection.write { db in From 094c919603e01f204643c0a08ca4977b023c8e84 Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Mon, 24 Nov 2025 17:37:09 +0000 Subject: [PATCH 19/20] Use a default duration for smart debounce --- .../PointOfSale/Presentation/Orders/POSOrderListView.swift | 2 +- Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift | 4 ++-- .../PointOfSale/Coupons/PointOfSaleCouponFetchStrategy.swift | 2 +- .../Items/PointOfSalePurchasableItemFetchStrategy.swift | 2 +- .../Yosemite/PointOfSale/Items/SearchDebounceStrategy.swift | 2 +- .../Mocks/MockPointOfSaleCouponsController.swift | 2 +- .../MockPointOfSalePurchasableItemsSearchController.swift | 2 +- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Modules/Sources/PointOfSale/Presentation/Orders/POSOrderListView.swift b/Modules/Sources/PointOfSale/Presentation/Orders/POSOrderListView.swift index f2a2ac270c0..8c56105571e 100644 --- a/Modules/Sources/PointOfSale/Presentation/Orders/POSOrderListView.swift +++ b/Modules/Sources/PointOfSale/Presentation/Orders/POSOrderListView.swift @@ -369,7 +369,7 @@ final class POSOrderSearchable: POSSearchable { // Use smart debouncing for order search to match original behavior: // don't debounce first keystroke to show loading immediately, // then debounce subsequent keystrokes while search is ongoing - .smart(duration: 500 * NSEC_PER_MSEC) + .smart() } var searchDebounceStrategy: SearchDebounceStrategy { diff --git a/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift b/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift index 25d843949a1..131c0485644 100644 --- a/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift +++ b/Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift @@ -107,7 +107,7 @@ final class PointOfSalePreviewCouponsController: PointOfSaleCouponsControllerPro itemsStack: ItemsStackState(root: .loading([]), itemStates: [:])) var currentDebounceStrategy: SearchDebounceStrategy { .immediate } - var searchDebounceStrategy: SearchDebounceStrategy { .smart(duration: 500 * NSEC_PER_MSEC) } + var searchDebounceStrategy: SearchDebounceStrategy { .smart() } func enableCoupons() async { } func loadItems(base: ItemListBaseItem) async { } func refreshItems(base: ItemListBaseItem) async { } @@ -122,7 +122,7 @@ final class PointOfSalePreviewItemsController: PointOfSaleSearchingItemsControll itemStates: [:])) var currentDebounceStrategy: SearchDebounceStrategy { .immediate } - var searchDebounceStrategy: SearchDebounceStrategy { .smart(duration: 500 * NSEC_PER_MSEC) } + var searchDebounceStrategy: SearchDebounceStrategy { .smart() } func loadItems(base: ItemListBaseItem) async { switch base { diff --git a/Modules/Sources/Yosemite/PointOfSale/Coupons/PointOfSaleCouponFetchStrategy.swift b/Modules/Sources/Yosemite/PointOfSale/Coupons/PointOfSaleCouponFetchStrategy.swift index f975a7b720e..38e5efa8a8a 100644 --- a/Modules/Sources/Yosemite/PointOfSale/Coupons/PointOfSaleCouponFetchStrategy.swift +++ b/Modules/Sources/Yosemite/PointOfSale/Coupons/PointOfSaleCouponFetchStrategy.swift @@ -102,7 +102,7 @@ struct PointOfSaleSearchCouponFetchStrategy: PointOfSaleCouponFetchStrategy { var debounceStrategy: SearchDebounceStrategy { // Use smart debouncing for remote coupon search // No loading delay threshold - show loading immediately for responsive feel - .smart(duration: 500 * NSEC_PER_MSEC) + .smart() } func fetchCoupons(pageNumber: Int) async throws -> PagedItems { diff --git a/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSalePurchasableItemFetchStrategy.swift b/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSalePurchasableItemFetchStrategy.swift index 859c6471832..90ab4d1a4c4 100644 --- a/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSalePurchasableItemFetchStrategy.swift +++ b/Modules/Sources/Yosemite/PointOfSale/Items/PointOfSalePurchasableItemFetchStrategy.swift @@ -85,7 +85,7 @@ public struct PointOfSaleSearchPurchasableItemFetchStrategy: PointOfSalePurchasa // Use smart debouncing for remote search: don't debounce first keystroke to show loading immediately, // then debounce subsequent keystrokes while search is ongoing. // No loading delay threshold - show loading immediately for responsive feel. - .smart(duration: 500 * NSEC_PER_MSEC) + .smart() } public func fetchProducts(pageNumber: Int) async throws -> PagedItems { diff --git a/Modules/Sources/Yosemite/PointOfSale/Items/SearchDebounceStrategy.swift b/Modules/Sources/Yosemite/PointOfSale/Items/SearchDebounceStrategy.swift index b9fa72deef3..7faa67bd7d3 100644 --- a/Modules/Sources/Yosemite/PointOfSale/Items/SearchDebounceStrategy.swift +++ b/Modules/Sources/Yosemite/PointOfSale/Items/SearchDebounceStrategy.swift @@ -8,7 +8,7 @@ public enum SearchDebounceStrategy: Equatable { /// - Parameters: /// - duration: The debounce duration in nanoseconds for subsequent keystrokes /// - loadingDelayThreshold: Optional threshold in nanoseconds before showing loading indicators. If nil, shows loading immediately. - case smart(duration: UInt64, loadingDelayThreshold: UInt64? = nil) + case smart(duration: UInt64 = 500 * NSEC_PER_MSEC, loadingDelayThreshold: UInt64? = nil) /// Simple debouncing: Always debounce every keystroke by the specified duration. /// Optionally delays showing loading indicators until a threshold is exceeded. diff --git a/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSaleCouponsController.swift b/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSaleCouponsController.swift index b55585d2ddc..5da65e0170e 100644 --- a/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSaleCouponsController.swift +++ b/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSaleCouponsController.swift @@ -10,7 +10,7 @@ final class MockPointOfSaleCouponsController: PointOfSaleCouponsControllerProtoc itemsStack: .init(root: .empty, itemStates: [:])) var currentDebounceStrategy: SearchDebounceStrategy = .immediate - var searchDebounceStrategy: SearchDebounceStrategy = .smart(duration: 500 * NSEC_PER_MSEC) + var searchDebounceStrategy: SearchDebounceStrategy = .smart() func loadItems(base: ItemListBaseItem) async { loadItemsCalled = true diff --git a/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSalePurchasableItemsSearchController.swift b/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSalePurchasableItemsSearchController.swift index b9ef4251b0d..4576e0606fb 100644 --- a/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSalePurchasableItemsSearchController.swift +++ b/Modules/Tests/PointOfSaleTests/Mocks/MockPointOfSalePurchasableItemsSearchController.swift @@ -9,7 +9,7 @@ final class MockPointOfSalePurchasableItemsSearchController: PointOfSaleSearchin itemsStack: .init(root: .empty, itemStates: [:])) var currentDebounceStrategy: SearchDebounceStrategy = .immediate - var searchDebounceStrategy: SearchDebounceStrategy = .smart(duration: 500 * NSEC_PER_MSEC) + var searchDebounceStrategy: SearchDebounceStrategy = .smart() func searchItems(searchTerm: String, baseItem: ItemListBaseItem) async {} From 6efe96f781bdea4059040721edfc2835ce0eeba3 Mon Sep 17 00:00:00 2001 From: Josh Heald Date: Mon, 24 Nov 2025 17:37:17 +0000 Subject: [PATCH 20/20] Split up debounce logic --- .../Item Search/POSSearchView.swift | 263 +++++++++--------- 1 file changed, 137 insertions(+), 126 deletions(-) diff --git a/Modules/Sources/PointOfSale/Presentation/Item Search/POSSearchView.swift b/Modules/Sources/PointOfSale/Presentation/Item Search/POSSearchView.swift index 9a217a6d0fc..396ca103e93 100644 --- a/Modules/Sources/PointOfSale/Presentation/Item Search/POSSearchView.swift +++ b/Modules/Sources/PointOfSale/Presentation/Item Search/POSSearchView.swift @@ -59,132 +59,7 @@ struct POSSearchField: View { .textInputAutocapitalization(.never) .focused($isSearchFieldFocused) .onChange(of: searchTerm) { oldValue, newValue in - // Cancel any ongoing search - searchTask?.cancel() - - // Capture the debounce strategy synchronously BEFORE creating the task. - // Use searchDebounceStrategy for non-empty search terms (actual searches), - // and currentDebounceStrategy for empty terms (returning to popular products). - let debounceStrategy = newValue.isNotEmpty ? searchable.searchDebounceStrategy : searchable.currentDebounceStrategy - - searchTask = Task { - // Apply debouncing based on the strategy captured at the start - switch debounceStrategy { - case .smart(let duration, let loadingDelayThreshold): - // Smart debouncing: Don't debounce first keystroke, but debounce subsequent keystrokes - // The loading indicator behavior depends on whether there's a threshold: - // - With threshold: Show loading after threshold if search hasn't completed (prevents flicker) - // - Without threshold: Show loading immediately (responsive feel) - - let shouldDebounceNextSearchRequest = !didFinishSearch - - // Early exit if search term is empty - guard newValue.isNotEmpty else { - didFinishSearch = true - return - } - - // Start loading indicator task if we have a threshold and this is first keystroke - let loadingTask: Task? - if !shouldDebounceNextSearchRequest { - // First keystroke - handle loading indicators - if let threshold = loadingDelayThreshold { - // With threshold: delay showing loading to prevent flicker for fast searches - loadingTask = Task { @MainActor in - try? await Task.sleep(nanoseconds: threshold) - if !Task.isCancelled { - searchable.clearSearchResults() - } - } - } else { - // No threshold - show loading immediately for responsive feel - searchable.clearSearchResults() - loadingTask = nil - } - } else { - // Subsequent keystrokes - loading already showing from previous search - loadingTask = nil - } - - if shouldDebounceNextSearchRequest { - try? await Task.sleep(nanoseconds: duration) - } - - // Now perform the search (common code for both and subsequent keystrokes) - guard !Task.isCancelled else { - loadingTask?.cancel() - return - } - - didFinishSearch = false - await searchable.performSearch(term: newValue) - - // Cancel loading task if search completed (only relevant for first keystroke with threshold) - loadingTask?.cancel() - - if !Task.isCancelled { - didFinishSearch = true - } - return - - case .simple(let duration, let loadingDelayThreshold): - // Simple debouncing: Always debounce - try? await Task.sleep(nanoseconds: duration) - - guard !Task.isCancelled else { return } - guard newValue.isNotEmpty else { - didFinishSearch = true - return - } - - didFinishSearch = false - - if let threshold = loadingDelayThreshold { - // Delay showing loading indicators to avoid flicker for fast queries - // Create a loading task that shows indicators after threshold - let loadingTask = Task { @MainActor in - try? await Task.sleep(nanoseconds: threshold) - // Only show loading if not cancelled - if !Task.isCancelled { - searchable.clearSearchResults() - } - } - - // Perform the search - await searchable.performSearch(term: newValue) - - // Cancel loading task if search completed before threshold - loadingTask.cancel() - } else { - // No loading delay threshold - show loading immediately - searchable.clearSearchResults() - await searchable.performSearch(term: newValue) - } - - if !Task.isCancelled { - didFinishSearch = true - } - return - - case .immediate: - // No debouncing - break - } - - guard !Task.isCancelled else { return } - - guard newValue.isNotEmpty else { - didFinishSearch = true - return - } - - didFinishSearch = false - await searchable.performSearch(term: newValue) - - if !Task.isCancelled { - didFinishSearch = true - } - } + handleSearchTermChange(newValue) } } .onChange(of: keyboardObserver.isKeyboardVisible) { _, isVisible in @@ -197,6 +72,142 @@ struct POSSearchField: View { } } +// MARK: - Search Handling +private extension POSSearchField { + func handleSearchTermChange(_ newValue: String) { + searchTask?.cancel() + + let debounceStrategy = selectDebounceStrategy(for: newValue) + + searchTask = Task { + await executeSearchWithStrategy(debounceStrategy, searchTerm: newValue) + } + } + + func selectDebounceStrategy(for searchTerm: String) -> SearchDebounceStrategy { + // Use searchDebounceStrategy for non-empty search terms (actual searches), + // and currentDebounceStrategy for empty terms (returning to popular products). + searchTerm.isNotEmpty ? searchable.searchDebounceStrategy : searchable.currentDebounceStrategy + } + + func executeSearchWithStrategy(_ strategy: SearchDebounceStrategy, searchTerm: String) async { + switch strategy { + case .smart(let duration, let loadingDelayThreshold): + await executeSmartDebouncedSearch(duration: duration, loadingDelayThreshold: loadingDelayThreshold, searchTerm: searchTerm) + case .simple(let duration, let loadingDelayThreshold): + await executeSimpleDebouncedSearch(duration: duration, loadingDelayThreshold: loadingDelayThreshold, searchTerm: searchTerm) + case .immediate: + await executeImmediateSearch(searchTerm: searchTerm) + } + } + + func executeSmartDebouncedSearch(duration: UInt64, loadingDelayThreshold: UInt64?, searchTerm: String) async { + // Smart debouncing: Don't debounce first keystroke, but debounce subsequent keystrokes + // The loading indicator behavior depends on whether there's a threshold: + // - With threshold: Show loading after threshold if search hasn't completed (prevents flicker) + // - Without threshold: Show loading immediately (responsive feel) + + let isFirstKeystroke = didFinishSearch + + guard searchTerm.isNotEmpty else { + didFinishSearch = true + return + } + + // Handle loading indicators for first keystroke + let loadingTask = isFirstKeystroke ? startLoadingIndicatorTask(threshold: loadingDelayThreshold) : nil + + // Debounce subsequent keystrokes + if !isFirstKeystroke { + try? await Task.sleep(nanoseconds: duration) + } + + guard !Task.isCancelled else { + loadingTask?.cancel() + return + } + + await performSearchAndTrackCompletion(searchTerm: searchTerm) + loadingTask?.cancel() + } + + func executeSimpleDebouncedSearch(duration: UInt64, + loadingDelayThreshold: UInt64?, + searchTerm: String) async { + // Simple debouncing: Always debounce every keystroke + try? await Task.sleep(nanoseconds: duration) + + guard !Task.isCancelled else { return } + guard searchTerm.isNotEmpty else { + didFinishSearch = true + return + } + + didFinishSearch = false + + if let threshold = loadingDelayThreshold { + await performSearchWithDelayedLoading(searchTerm: searchTerm, threshold: threshold) + } else { + searchable.clearSearchResults() + await searchable.performSearch(term: searchTerm) + } + + if !Task.isCancelled { + didFinishSearch = true + } + } + + func executeImmediateSearch(searchTerm: String) async { + guard !Task.isCancelled else { return } + guard searchTerm.isNotEmpty else { + didFinishSearch = true + return + } + + await performSearchAndTrackCompletion(searchTerm: searchTerm) + } + + func startLoadingIndicatorTask(threshold: UInt64?) -> Task? { + if let threshold { + // With threshold: delay showing loading to prevent flicker for fast searches + return Task { @MainActor in + try? await Task.sleep(nanoseconds: threshold) + if !Task.isCancelled { + searchable.clearSearchResults() + } + } + } else { + // No threshold - show loading immediately for responsive feel + searchable.clearSearchResults() + return nil + } + } + + func performSearchWithDelayedLoading(searchTerm: String, threshold: UInt64) async { + // Create a loading task that shows indicators after threshold + let loadingTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: threshold) + if !Task.isCancelled { + searchable.clearSearchResults() + } + } + + await searchable.performSearch(term: searchTerm) + + // Cancel loading task if search completed before threshold + loadingTask.cancel() + } + + private func performSearchAndTrackCompletion(searchTerm: String) async { + didFinishSearch = false + await searchable.performSearch(term: searchTerm) + + if !Task.isCancelled { + didFinishSearch = true + } + } +} + /// A reusable search content view for POS items struct POSSearchContentView: View { @Environment(\.posAnalytics) private var analytics