Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
9afae0b
[Local catalog] Add analytics tracking for local search
joshheald Nov 19, 2025
9d13321
Add SearchDebounceStrategy enum for configurable search debouncing
joshheald Nov 20, 2025
3637ba0
Add debounceStrategy property to fetch strategy protocols
joshheald Nov 19, 2025
6acb890
Implement strategy-based debouncing in search UI
joshheald Nov 19, 2025
5dae392
Apply simple debouncing strategy to local product search
joshheald Nov 19, 2025
753ca24
Add tests for SearchDebounceStrategy
joshheald Nov 19, 2025
a81e1c7
Fix line length lint violations in SearchDebounceStrategyTests
joshheald Nov 20, 2025
b78f674
Fix SearchDebounceStrategyTests to use existing mocks
joshheald Nov 20, 2025
fe4b43c
Merge PR3: includes SQL LIKE escaping fix
joshheald Nov 20, 2025
ebe7eaa
Merge PR4: includes SQL LIKE escaping fix
joshheald Nov 20, 2025
976fff5
Merge PR3: GRDBManager access fix
joshheald Nov 20, 2025
f112338
Merge PR4: GRDBManager access fix
joshheald Nov 20, 2025
60f51e1
Add currentDebounceStrategy to mock controllers
joshheald Nov 20, 2025
d37005d
Fix lint
joshheald Nov 20, 2025
adf6e1f
Merge factory-integration branch with version fix
joshheald Nov 20, 2025
3aa198b
Merge analytics branch with version fix
joshheald Nov 20, 2025
0a668f1
Add loadingDelayThreshold to smart debounce strategy
joshheald Nov 20, 2025
9656089
Fix loading indicators not showing on first remote search
joshheald Nov 20, 2025
de5a625
Remove unnecessary comment
joshheald Nov 20, 2025
42335af
Fix test build
joshheald Nov 20, 2025
5e4e396
Fix periphery issues
joshheald Nov 20, 2025
2dc3ff6
Rename debounceStrategy to currentDebounceStrategy for clarity
joshheald Nov 20, 2025
61837ab
Merge branch 'woomob-1112-woo-poslocal-catalog-factory-integration' i…
joshheald Nov 21, 2025
59f976f
Merge branch 'woomob-1112-woo-poslocal-catalog-analytics' into woomob…
joshheald Nov 21, 2025
d1bf02e
Merge branch 'woomob-1112-woo-poslocal-catalog-factory-integration' i…
joshheald Nov 21, 2025
bf2d785
Merge branch 'woomob-1112-woo-poslocal-catalog-analytics' into woomob…
joshheald Nov 21, 2025
992afaf
Escape the escape!
joshheald Nov 21, 2025
37b4e98
Don’t show intermittent cancellation errors in search
joshheald Nov 21, 2025
6d45ae6
Remove unnecessary `await` call
joshheald Nov 24, 2025
094c919
Use a default duration for smart debounce
joshheald Nov 24, 2025
6efe96f
Split up debounce logic
joshheald Nov 24, 2025
851119c
[Local catalog] Add configurable search debounce strategies (#16377)
joshheald Nov 24, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,15 @@ struct POSItemFetchAnalytics: POSItemFetchAnalyticsTracking {

/// Tracks when a local search results fetch completes
/// - Parameters:
/// - milliseconds: The time taken to fetch results in milliseconds
/// - millisecondsSinceRequestSent: The time taken to fetch results in milliseconds
/// - totalItems: The total number of items found in the search
func trackSearchLocalResultsFetchComplete(millisecondsSinceRequestSent: Int, totalItems: Int) {
// TODO: Implement analytics event for local search results
// This will be implemented in the final PR
analytics.track(
event: .PointOfSale.pointOfSaleSearchResultsFetched(
itemType: itemType,
resultsCount: totalItems,
millisecondsSinceRequestSent: millisecondsSinceRequestSent
)
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,17 @@ extension WooAnalyticsEvent {
])
}

static func pointOfSaleSearchResultsFetched(itemType: POSItemType,
resultsCount: Int,
millisecondsSinceRequestSent: Int) -> WooAnalyticsEvent {
WooAnalyticsEvent(statName: .pointOfSaleSearchResultsFetched,
properties: [
Key.sourceView: SourceView(itemType: itemType).rawValue,
Key.resultsCount: "\(resultsCount)",
Key.millisecondsSinceRequestSent: "\(millisecondsSinceRequestSent)"
])
}

static func pointOfSaleItemsFetched(itemType: POSItemType,
totalItems: Int) -> WooAnalyticsEvent {
WooAnalyticsEvent(statName: .pointOfSaleItemsFetched,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -57,6 +58,18 @@ protocol PointOfSaleCouponsControllerProtocol: PointOfSaleSearchingItemsControll
setSearchingState()
}

var currentDebounceStrategy: SearchDebounceStrategy {
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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import struct Yosemite.POSVariableParentProduct
import class Yosemite.Store
import enum Yosemite.POSItemType
import class Yosemite.AsyncPaginationTracker
import enum Yosemite.SearchDebounceStrategy

protocol PointOfSaleItemsControllerProtocol {
///
Expand All @@ -27,6 +28,10 @@ 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 }
/// The debouncing strategy that will be used when performing a search
var searchDebounceStrategy: SearchDebounceStrategy { get }
}


Expand Down Expand Up @@ -70,14 +75,26 @@ protocol PointOfSaleSearchingItemsControllerProtocol: PointOfSaleItemsController
fetchStrategy = itemFetchStrategyFactory.searchStrategy(searchTerm: searchTerm,
analytics: POSItemFetchAnalytics(itemType: .product,
analytics: analyticsProvider))
setSearchingState(base: baseItem)
await loadFirstPage(base: baseItem)
}

func clearSearchItems(baseItem: ItemListBaseItem) {
setSearchingState(base: baseItem)
}

var currentDebounceStrategy: SearchDebounceStrategy {
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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -24,6 +25,14 @@ final class POSProductSearchable: POSSearchable {
itemListType.itemType.searchFieldLabel
}

var currentDebounceStrategy: SearchDebounceStrategy {
itemsController.currentDebounceStrategy
}

var searchDebounceStrategy: SearchDebounceStrategy {
itemsController.searchDebounceStrategy
}

func performSearch(term: String) async {
await itemsController.searchItems(searchTerm: term, baseItem: .root)
}
Expand Down
176 changes: 142 additions & 34 deletions Modules/Sources/PointOfSale/Presentation/Item Search/POSSearchView.swift
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
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 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 }

/// Called when a search should be performed
/// - Parameter term: The search term to use
Expand Down Expand Up @@ -54,40 +59,7 @@ 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
searchTask?.cancel()

searchTask = Task {
if shouldDebounceNextSearchRequest {
try? await Task.sleep(nanoseconds: 500 * NSEC_PER_MSEC)
} else {
searchable.clearSearchResults()
}

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
Expand All @@ -100,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<Void, Never>? {
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<Content: View>: View {
@Environment(\.posAnalytics) private var analytics
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -364,6 +365,18 @@ final class POSOrderSearchable: POSSearchable {
[]
}

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
.smart()
}

var searchDebounceStrategy: SearchDebounceStrategy {
// Orders use the same strategy for both modes
currentDebounceStrategy
}

func performSearch(term: String) async {
await ordersController.searchOrders(searchTerm: term)
}
Expand Down
6 changes: 6 additions & 0 deletions Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -105,6 +106,8 @@ final class PointOfSalePreviewCouponsController: PointOfSaleCouponsControllerPro
@Published var itemsViewState: ItemsViewState = ItemsViewState(containerState: .loading(),
itemsStack: ItemsStackState(root: .loading([]),
itemStates: [:]))
var currentDebounceStrategy: SearchDebounceStrategy { .immediate }
var searchDebounceStrategy: SearchDebounceStrategy { .smart() }
func enableCoupons() async { }
func loadItems(base: ItemListBaseItem) async { }
func refreshItems(base: ItemListBaseItem) async { }
Expand All @@ -118,6 +121,9 @@ final class PointOfSalePreviewItemsController: PointOfSaleSearchingItemsControll
itemsStack: ItemsStackState(root: .loading([]),
itemStates: [:]))

var currentDebounceStrategy: SearchDebounceStrategy { .immediate }
var searchDebounceStrategy: SearchDebounceStrategy { .smart() }

func loadItems(base: ItemListBaseItem) async {
switch base {
case .root:
Expand Down
1 change: 1 addition & 0 deletions Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: "\\_")
}
Expand Down
Loading