Skip to content

Commit 5b71a8e

Browse files
authored
[Local catalog] Add analytics tracking for local search (#16376)
2 parents 3945526 + 851119c commit 5b71a8e

19 files changed

+482
-44
lines changed

Modules/Sources/PointOfSale/Analytics/POSItemFetchAnalytics.swift

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,15 @@ struct POSItemFetchAnalytics: POSItemFetchAnalyticsTracking {
4646

4747
/// Tracks when a local search results fetch completes
4848
/// - Parameters:
49-
/// - milliseconds: The time taken to fetch results in milliseconds
49+
/// - millisecondsSinceRequestSent: The time taken to fetch results in milliseconds
5050
/// - totalItems: The total number of items found in the search
5151
func trackSearchLocalResultsFetchComplete(millisecondsSinceRequestSent: Int, totalItems: Int) {
52-
// TODO: Implement analytics event for local search results
53-
// This will be implemented in the final PR
52+
analytics.track(
53+
event: .PointOfSale.pointOfSaleSearchResultsFetched(
54+
itemType: itemType,
55+
resultsCount: totalItems,
56+
millisecondsSinceRequestSent: millisecondsSinceRequestSent
57+
)
58+
)
5459
}
5560
}

Modules/Sources/PointOfSale/Analytics/WooAnalyticsEvent+PointOfSale.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,17 @@ extension WooAnalyticsEvent {
305305
])
306306
}
307307

308+
static func pointOfSaleSearchResultsFetched(itemType: POSItemType,
309+
resultsCount: Int,
310+
millisecondsSinceRequestSent: Int) -> WooAnalyticsEvent {
311+
WooAnalyticsEvent(statName: .pointOfSaleSearchResultsFetched,
312+
properties: [
313+
Key.sourceView: SourceView(itemType: itemType).rawValue,
314+
Key.resultsCount: "\(resultsCount)",
315+
Key.millisecondsSinceRequestSent: "\(millisecondsSinceRequestSent)"
316+
])
317+
}
318+
308319
static func pointOfSaleItemsFetched(itemType: POSItemType,
309320
totalItems: Int) -> WooAnalyticsEvent {
310321
WooAnalyticsEvent(statName: .pointOfSaleItemsFetched,

Modules/Sources/PointOfSale/Controllers/PointOfSaleCouponsController.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import protocol Yosemite.PointOfSaleCouponServiceProtocol
77
import struct Yosemite.PointOfSaleCouponFetchStrategyFactory
88
import protocol Yosemite.PointOfSaleCouponFetchStrategy
99
import class Yosemite.AsyncPaginationTracker
10+
import enum Yosemite.SearchDebounceStrategy
1011

1112
protocol PointOfSaleCouponsControllerProtocol: PointOfSaleSearchingItemsControllerProtocol {
1213
/// Enables coupons in store settings
@@ -57,6 +58,18 @@ protocol PointOfSaleCouponsControllerProtocol: PointOfSaleSearchingItemsControll
5758
setSearchingState()
5859
}
5960

61+
var currentDebounceStrategy: SearchDebounceStrategy {
62+
fetchStrategy.debounceStrategy
63+
}
64+
65+
var searchDebounceStrategy: SearchDebounceStrategy {
66+
// Return the debounce strategy that would be used for a search
67+
let searchStrategy = fetchStrategyFactory.searchStrategy(searchTerm: "",
68+
analytics: POSItemFetchAnalytics(itemType: .coupon,
69+
analytics: analyticsProvider))
70+
return searchStrategy.debounceStrategy
71+
}
72+
6073
@MainActor
6174
func loadNextItems(base: ItemListBaseItem) async {
6275
guard paginationTracker.hasNextPage else {

Modules/Sources/PointOfSale/Controllers/PointOfSaleItemsController.swift

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import struct Yosemite.POSVariableParentProduct
1111
import class Yosemite.Store
1212
import enum Yosemite.POSItemType
1313
import class Yosemite.AsyncPaginationTracker
14+
import enum Yosemite.SearchDebounceStrategy
1415

1516
protocol PointOfSaleItemsControllerProtocol {
1617
///
@@ -27,6 +28,10 @@ protocol PointOfSaleSearchingItemsControllerProtocol: PointOfSaleItemsController
2728
/// Searches for items
2829
func searchItems(searchTerm: String, baseItem: ItemListBaseItem) async
2930
func clearSearchItems(baseItem: ItemListBaseItem)
31+
/// The debouncing strategy from the current fetch strategy
32+
var currentDebounceStrategy: SearchDebounceStrategy { get }
33+
/// The debouncing strategy that will be used when performing a search
34+
var searchDebounceStrategy: SearchDebounceStrategy { get }
3035
}
3136

3237

@@ -70,14 +75,26 @@ protocol PointOfSaleSearchingItemsControllerProtocol: PointOfSaleItemsController
7075
fetchStrategy = itemFetchStrategyFactory.searchStrategy(searchTerm: searchTerm,
7176
analytics: POSItemFetchAnalytics(itemType: .product,
7277
analytics: analyticsProvider))
73-
setSearchingState(base: baseItem)
7478
await loadFirstPage(base: baseItem)
7579
}
7680

7781
func clearSearchItems(baseItem: ItemListBaseItem) {
7882
setSearchingState(base: baseItem)
7983
}
8084

85+
var currentDebounceStrategy: SearchDebounceStrategy {
86+
fetchStrategy.debounceStrategy
87+
}
88+
89+
var searchDebounceStrategy: SearchDebounceStrategy {
90+
// Return the debounce strategy that would be used for a search
91+
// We create a temporary search strategy to get its debounce settings
92+
let searchStrategy = itemFetchStrategyFactory.searchStrategy(searchTerm: "",
93+
analytics: POSItemFetchAnalytics(itemType: .product,
94+
analytics: analyticsProvider))
95+
return searchStrategy.debounceStrategy
96+
}
97+
8198
@MainActor
8299
private func loadFirstPage(base: ItemListBaseItem) async {
83100
switch base {

Modules/Sources/PointOfSale/Presentation/Item Search/POSProductSearchable.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import Foundation
22
import enum Yosemite.POSItemType
33
import protocol Yosemite.POSSearchHistoryProviding
44
import enum Yosemite.POSItem
5+
import enum Yosemite.SearchDebounceStrategy
56

67
final class POSProductSearchable: POSSearchable {
78
private let itemListType: ItemListType
@@ -24,6 +25,14 @@ final class POSProductSearchable: POSSearchable {
2425
itemListType.itemType.searchFieldLabel
2526
}
2627

28+
var currentDebounceStrategy: SearchDebounceStrategy {
29+
itemsController.currentDebounceStrategy
30+
}
31+
32+
var searchDebounceStrategy: SearchDebounceStrategy {
33+
itemsController.searchDebounceStrategy
34+
}
35+
2736
func performSearch(term: String) async {
2837
await itemsController.searchItems(searchTerm: term, baseItem: .root)
2938
}

Modules/Sources/PointOfSale/Presentation/Item Search/POSSearchView.swift

Lines changed: 142 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
import SwiftUI
22
import enum Yosemite.POSItemType
33
import enum Yosemite.POSItem
4+
import enum Yosemite.SearchDebounceStrategy
45

56
/// Protocol defining search capabilities for POS items
67
protocol POSSearchable {
78
var searchFieldPlaceholder: String { get }
89
/// Recent search history for the current item type
910
var searchHistory: [String] { get }
11+
/// The debouncing strategy currently active based on the controller's current state
12+
var currentDebounceStrategy: SearchDebounceStrategy { get }
13+
/// The debouncing strategy that will be used when performing a search (may differ from current strategy)
14+
var searchDebounceStrategy: SearchDebounceStrategy { get }
1015

1116
/// Called when a search should be performed
1217
/// - Parameter term: The search term to use
@@ -54,40 +59,7 @@ struct POSSearchField: View {
5459
.textInputAutocapitalization(.never)
5560
.focused($isSearchFieldFocused)
5661
.onChange(of: searchTerm) { oldValue, newValue in
57-
// The debouncing logic is a little tricky, because the loading state is held in the controller.
58-
// Arguably, we should use view state `isSearching` for this, so the UI is independent of the request timing.
59-
60-
// As the user types, we don't want to send every keystroke to the remote, so we debounce the requests.
61-
// However, we don't want to debounce the first keystroke of a new search, so that the loading
62-
// state shows immediately and the UI feels responsive.
63-
64-
// So, if the last search was finished, we don't debounce the first character. If it didn't
65-
// finish i.e. it is still ongoing, we debounce the next keystrokes by 300ms. In either case,
66-
// the ongoing search is redundant now there's a new search term, so we cancel it.
67-
let shouldDebounceNextSearchRequest = !didFinishSearch
68-
searchTask?.cancel()
69-
70-
searchTask = Task {
71-
if shouldDebounceNextSearchRequest {
72-
try? await Task.sleep(nanoseconds: 500 * NSEC_PER_MSEC)
73-
} else {
74-
searchable.clearSearchResults()
75-
}
76-
77-
guard !Task.isCancelled else { return }
78-
79-
guard newValue.isNotEmpty else {
80-
didFinishSearch = true
81-
return
82-
}
83-
84-
didFinishSearch = false
85-
await searchable.performSearch(term: newValue)
86-
87-
if !Task.isCancelled {
88-
didFinishSearch = true
89-
}
90-
}
62+
handleSearchTermChange(newValue)
9163
}
9264
}
9365
.onChange(of: keyboardObserver.isKeyboardVisible) { _, isVisible in
@@ -100,6 +72,142 @@ struct POSSearchField: View {
10072
}
10173
}
10274

75+
// MARK: - Search Handling
76+
private extension POSSearchField {
77+
func handleSearchTermChange(_ newValue: String) {
78+
searchTask?.cancel()
79+
80+
let debounceStrategy = selectDebounceStrategy(for: newValue)
81+
82+
searchTask = Task {
83+
await executeSearchWithStrategy(debounceStrategy, searchTerm: newValue)
84+
}
85+
}
86+
87+
func selectDebounceStrategy(for searchTerm: String) -> SearchDebounceStrategy {
88+
// Use searchDebounceStrategy for non-empty search terms (actual searches),
89+
// and currentDebounceStrategy for empty terms (returning to popular products).
90+
searchTerm.isNotEmpty ? searchable.searchDebounceStrategy : searchable.currentDebounceStrategy
91+
}
92+
93+
func executeSearchWithStrategy(_ strategy: SearchDebounceStrategy, searchTerm: String) async {
94+
switch strategy {
95+
case .smart(let duration, let loadingDelayThreshold):
96+
await executeSmartDebouncedSearch(duration: duration, loadingDelayThreshold: loadingDelayThreshold, searchTerm: searchTerm)
97+
case .simple(let duration, let loadingDelayThreshold):
98+
await executeSimpleDebouncedSearch(duration: duration, loadingDelayThreshold: loadingDelayThreshold, searchTerm: searchTerm)
99+
case .immediate:
100+
await executeImmediateSearch(searchTerm: searchTerm)
101+
}
102+
}
103+
104+
func executeSmartDebouncedSearch(duration: UInt64, loadingDelayThreshold: UInt64?, searchTerm: String) async {
105+
// Smart debouncing: Don't debounce first keystroke, but debounce subsequent keystrokes
106+
// The loading indicator behavior depends on whether there's a threshold:
107+
// - With threshold: Show loading after threshold if search hasn't completed (prevents flicker)
108+
// - Without threshold: Show loading immediately (responsive feel)
109+
110+
let isFirstKeystroke = didFinishSearch
111+
112+
guard searchTerm.isNotEmpty else {
113+
didFinishSearch = true
114+
return
115+
}
116+
117+
// Handle loading indicators for first keystroke
118+
let loadingTask = isFirstKeystroke ? startLoadingIndicatorTask(threshold: loadingDelayThreshold) : nil
119+
120+
// Debounce subsequent keystrokes
121+
if !isFirstKeystroke {
122+
try? await Task.sleep(nanoseconds: duration)
123+
}
124+
125+
guard !Task.isCancelled else {
126+
loadingTask?.cancel()
127+
return
128+
}
129+
130+
await performSearchAndTrackCompletion(searchTerm: searchTerm)
131+
loadingTask?.cancel()
132+
}
133+
134+
func executeSimpleDebouncedSearch(duration: UInt64,
135+
loadingDelayThreshold: UInt64?,
136+
searchTerm: String) async {
137+
// Simple debouncing: Always debounce every keystroke
138+
try? await Task.sleep(nanoseconds: duration)
139+
140+
guard !Task.isCancelled else { return }
141+
guard searchTerm.isNotEmpty else {
142+
didFinishSearch = true
143+
return
144+
}
145+
146+
didFinishSearch = false
147+
148+
if let threshold = loadingDelayThreshold {
149+
await performSearchWithDelayedLoading(searchTerm: searchTerm, threshold: threshold)
150+
} else {
151+
searchable.clearSearchResults()
152+
await searchable.performSearch(term: searchTerm)
153+
}
154+
155+
if !Task.isCancelled {
156+
didFinishSearch = true
157+
}
158+
}
159+
160+
func executeImmediateSearch(searchTerm: String) async {
161+
guard !Task.isCancelled else { return }
162+
guard searchTerm.isNotEmpty else {
163+
didFinishSearch = true
164+
return
165+
}
166+
167+
await performSearchAndTrackCompletion(searchTerm: searchTerm)
168+
}
169+
170+
func startLoadingIndicatorTask(threshold: UInt64?) -> Task<Void, Never>? {
171+
if let threshold {
172+
// With threshold: delay showing loading to prevent flicker for fast searches
173+
return Task { @MainActor in
174+
try? await Task.sleep(nanoseconds: threshold)
175+
if !Task.isCancelled {
176+
searchable.clearSearchResults()
177+
}
178+
}
179+
} else {
180+
// No threshold - show loading immediately for responsive feel
181+
searchable.clearSearchResults()
182+
return nil
183+
}
184+
}
185+
186+
func performSearchWithDelayedLoading(searchTerm: String, threshold: UInt64) async {
187+
// Create a loading task that shows indicators after threshold
188+
let loadingTask = Task { @MainActor in
189+
try? await Task.sleep(nanoseconds: threshold)
190+
if !Task.isCancelled {
191+
searchable.clearSearchResults()
192+
}
193+
}
194+
195+
await searchable.performSearch(term: searchTerm)
196+
197+
// Cancel loading task if search completed before threshold
198+
loadingTask.cancel()
199+
}
200+
201+
private func performSearchAndTrackCompletion(searchTerm: String) async {
202+
didFinishSearch = false
203+
await searchable.performSearch(term: searchTerm)
204+
205+
if !Task.isCancelled {
206+
didFinishSearch = true
207+
}
208+
}
209+
}
210+
103211
/// A reusable search content view for POS items
104212
struct POSSearchContentView<Content: View>: View {
105213
@Environment(\.posAnalytics) private var analytics

Modules/Sources/PointOfSale/Presentation/Orders/POSOrderListView.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import SwiftUI
22
import struct WooFoundation.WooAnalyticsEvent
33
import struct Yosemite.POSOrder
44
import enum Yosemite.OrderPaymentMethod
5+
import enum Yosemite.SearchDebounceStrategy
56

67
struct POSOrderListView: View {
78
@Binding var isSearching: Bool
@@ -364,6 +365,18 @@ final class POSOrderSearchable: POSSearchable {
364365
[]
365366
}
366367

368+
var currentDebounceStrategy: SearchDebounceStrategy {
369+
// Use smart debouncing for order search to match original behavior:
370+
// don't debounce first keystroke to show loading immediately,
371+
// then debounce subsequent keystrokes while search is ongoing
372+
.smart()
373+
}
374+
375+
var searchDebounceStrategy: SearchDebounceStrategy {
376+
// Orders use the same strategy for both modes
377+
currentDebounceStrategy
378+
}
379+
367380
func performSearch(term: String) async {
368381
await ordersController.searchOrders(searchTerm: term)
369382
}

Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import struct Yosemite.POSProduct
1818
import struct Yosemite.POSProductVariation
1919
import protocol Yosemite.POSSearchHistoryProviding
2020
import enum Yosemite.POSItemType
21+
import enum Yosemite.SearchDebounceStrategy
2122
import protocol Yosemite.PointOfSaleBarcodeScanServiceProtocol
2223
import enum Yosemite.PointOfSaleBarcodeScanError
2324
import Combine
@@ -105,6 +106,8 @@ final class PointOfSalePreviewCouponsController: PointOfSaleCouponsControllerPro
105106
@Published var itemsViewState: ItemsViewState = ItemsViewState(containerState: .loading(),
106107
itemsStack: ItemsStackState(root: .loading([]),
107108
itemStates: [:]))
109+
var currentDebounceStrategy: SearchDebounceStrategy { .immediate }
110+
var searchDebounceStrategy: SearchDebounceStrategy { .smart() }
108111
func enableCoupons() async { }
109112
func loadItems(base: ItemListBaseItem) async { }
110113
func refreshItems(base: ItemListBaseItem) async { }
@@ -118,6 +121,9 @@ final class PointOfSalePreviewItemsController: PointOfSaleSearchingItemsControll
118121
itemsStack: ItemsStackState(root: .loading([]),
119122
itemStates: [:]))
120123

124+
var currentDebounceStrategy: SearchDebounceStrategy { .immediate }
125+
var searchDebounceStrategy: SearchDebounceStrategy { .smart() }
126+
121127
func loadItems(base: ItemListBaseItem) async {
122128
switch base {
123129
case .root:

Modules/Sources/Storage/GRDB/Model/PersistedProduct.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ public extension PersistedProduct {
156156
/// - Returns: An escaped pattern safe for use in LIKE queries
157157
private static func escapeSQLLikePattern(_ pattern: String) -> String {
158158
pattern
159+
.replacingOccurrences(of: "\\", with: "\\\\")
159160
.replacingOccurrences(of: "%", with: "\\%")
160161
.replacingOccurrences(of: "_", with: "\\_")
161162
}

0 commit comments

Comments
 (0)