|
@@ -75,14 +75,33 @@ final class JobSearchViewModel {
|
|
|
func submitSearch() {
|
|
func submitSearch() {
|
|
|
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
guard !trimmed.isEmpty else { return }
|
|
guard !trimmed.isEmpty else { return }
|
|
|
- runProgressiveSearch(query: trimmed, criteria: criteria, reinterpret: true)
|
|
|
|
|
|
|
+ filterDebounceTask?.cancel()
|
|
|
|
|
+
|
|
|
|
|
+ // A new search-box query owns title/keywords. Previous AI-filled filter values
|
|
|
|
|
+ // must not win in SearchCriteriaMerge, or Indeed keeps searching the old title.
|
|
|
|
|
+ var searchCriteria = criteria
|
|
|
|
|
+ searchCriteria.jobTitle = ""
|
|
|
|
|
+ searchCriteria.keywords = ""
|
|
|
|
|
+ searchCriteria.exactPhrase = false
|
|
|
|
|
+
|
|
|
|
|
+ runProgressiveSearch(
|
|
|
|
|
+ query: trimmed,
|
|
|
|
|
+ criteria: searchCriteria,
|
|
|
|
|
+ reinterpret: true,
|
|
|
|
|
+ includeEnrichment: true
|
|
|
|
|
+ )
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
func applyFiltersAndRefresh() {
|
|
func applyFiltersAndRefresh() {
|
|
|
guard hasSearched, !suppressFilterRefresh else { return }
|
|
guard hasSearched, !suppressFilterRefresh else { return }
|
|
|
|
|
+
|
|
|
|
|
+ // Instant local narrowing so checkboxes/text feel responsive while the
|
|
|
|
|
+ // network refresh is debounced.
|
|
|
|
|
+ applyLocalFilterPreview()
|
|
|
|
|
+
|
|
|
filterDebounceTask?.cancel()
|
|
filterDebounceTask?.cancel()
|
|
|
filterDebounceTask = Task {
|
|
filterDebounceTask = Task {
|
|
|
- try? await Task.sleep(nanoseconds: 350_000_000)
|
|
|
|
|
|
|
+ try? await Task.sleep(nanoseconds: 160_000_000)
|
|
|
if Task.isCancelled || suppressFilterRefresh { return }
|
|
if Task.isCancelled || suppressFilterRefresh { return }
|
|
|
refreshResultsOnly()
|
|
refreshResultsOnly()
|
|
|
}
|
|
}
|
|
@@ -99,30 +118,63 @@ final class JobSearchViewModel {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/// Re-runs fetch/filter/rank with current criteria without re-interpreting the query.
|
|
/// Re-runs fetch/filter/rank with current criteria without re-interpreting the query.
|
|
|
|
|
+ /// Skips live scrape enrichment so filter changes stay fast.
|
|
|
private func refreshResultsOnly() {
|
|
private func refreshResultsOnly() {
|
|
|
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
- runProgressiveSearch(query: trimmed, criteria: criteria, reinterpret: false)
|
|
|
|
|
|
|
+ runProgressiveSearch(
|
|
|
|
|
+ query: trimmed,
|
|
|
|
|
+ criteria: criteria,
|
|
|
|
|
+ reinterpret: false,
|
|
|
|
|
+ includeEnrichment: false
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Narrows already-loaded rows immediately; network refresh corrects/expands afterward.
|
|
|
|
|
+ private func applyLocalFilterPreview() {
|
|
|
|
|
+ guard !results.isEmpty else { return }
|
|
|
|
|
+ let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
+ let narrowed = JobListingFilter.filter(results, criteria: criteria, query: trimmed)
|
|
|
|
|
+ guard narrowed.count != results.count else { return }
|
|
|
|
|
+ results = narrowed
|
|
|
|
|
+ totalResultCount = narrowed.count
|
|
|
|
|
+ if narrowed.isEmpty {
|
|
|
|
|
+ state = .loading
|
|
|
|
|
+ } else if state != .loading {
|
|
|
|
|
+ state = .success
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private func runProgressiveSearch(
|
|
private func runProgressiveSearch(
|
|
|
query trimmed: String,
|
|
query trimmed: String,
|
|
|
criteria searchCriteria: SearchCriteria,
|
|
criteria searchCriteria: SearchCriteria,
|
|
|
- reinterpret: Bool
|
|
|
|
|
|
|
+ reinterpret: Bool,
|
|
|
|
|
+ includeEnrichment: Bool
|
|
|
) {
|
|
) {
|
|
|
searchTask?.cancel()
|
|
searchTask?.cancel()
|
|
|
|
|
+ // Filter refreshes keep the local preview on screen; full NL searches clear first.
|
|
|
|
|
+ // Do not set `isLoadingMore` here — that flag means "append enrichment under primary",
|
|
|
|
|
+ // and would prevent replacing results with the newly filtered fetch.
|
|
|
|
|
+ let preserveResultsWhileLoading = !reinterpret && !results.isEmpty
|
|
|
searchTask = Task {
|
|
searchTask = Task {
|
|
|
- state = .loading
|
|
|
|
|
- isLoadingMore = false
|
|
|
|
|
|
|
+ if preserveResultsWhileLoading {
|
|
|
|
|
+ if state != .success && state != .loading {
|
|
|
|
|
+ state = .loading
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ state = .loading
|
|
|
|
|
+ isLoadingMore = false
|
|
|
|
|
+ results = []
|
|
|
|
|
+ totalResultCount = 0
|
|
|
|
|
+ }
|
|
|
hasSearched = true
|
|
hasSearched = true
|
|
|
- results = []
|
|
|
|
|
- totalResultCount = 0
|
|
|
|
|
|
|
|
|
|
do {
|
|
do {
|
|
|
let stream = pipeline.searchProgressive(
|
|
let stream = pipeline.searchProgressive(
|
|
|
JobSearchPipelineRequest(
|
|
JobSearchPipelineRequest(
|
|
|
naturalLanguageQuery: trimmed,
|
|
naturalLanguageQuery: trimmed,
|
|
|
userCriteria: searchCriteria,
|
|
userCriteria: searchCriteria,
|
|
|
- reinterpret: reinterpret
|
|
|
|
|
|
|
+ reinterpret: reinterpret,
|
|
|
|
|
+ includeEnrichment: includeEnrichment
|
|
|
)
|
|
)
|
|
|
)
|
|
)
|
|
|
|
|
|
|
@@ -187,9 +239,9 @@ final class JobSearchViewModel {
|
|
|
suppressFilterRefresh = true
|
|
suppressFilterRefresh = true
|
|
|
criteria = newCriteria
|
|
criteria = newCriteria
|
|
|
Task { @MainActor in
|
|
Task { @MainActor in
|
|
|
- // Cover FiltersPanel onChange + its 350ms debounce so interpretation
|
|
|
|
|
|
|
+ // Cover FiltersPanel onChange + its debounce so interpretation
|
|
|
// does not start a second search on top of enrichment.
|
|
// does not start a second search on top of enrichment.
|
|
|
- try? await Task.sleep(nanoseconds: 500_000_000)
|
|
|
|
|
|
|
+ try? await Task.sleep(nanoseconds: 400_000_000)
|
|
|
suppressFilterRefresh = false
|
|
suppressFilterRefresh = false
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|