|
|
@@ -37,6 +37,8 @@ final class JobSearchViewModel {
|
|
|
private let savedStore: MockSavedJobsStore
|
|
|
private var searchTask: Task<Void, Never>?
|
|
|
private var filterDebounceTask: Task<Void, Never>?
|
|
|
+ /// Ignores filter-panel onChange while pipeline-applied criteria propagates to the UI.
|
|
|
+ private var suppressFilterRefresh = false
|
|
|
|
|
|
init(dependencies: AppDependencies) {
|
|
|
self.pipeline = dependencies.searchPipeline
|
|
|
@@ -77,11 +79,11 @@ final class JobSearchViewModel {
|
|
|
}
|
|
|
|
|
|
func applyFiltersAndRefresh() {
|
|
|
- guard hasSearched else { return }
|
|
|
+ guard hasSearched, !suppressFilterRefresh else { return }
|
|
|
filterDebounceTask?.cancel()
|
|
|
filterDebounceTask = Task {
|
|
|
try? await Task.sleep(nanoseconds: 350_000_000)
|
|
|
- if Task.isCancelled { return }
|
|
|
+ if Task.isCancelled || suppressFilterRefresh { return }
|
|
|
refreshResultsOnly()
|
|
|
}
|
|
|
}
|
|
|
@@ -127,8 +129,8 @@ final class JobSearchViewModel {
|
|
|
for try await progress in stream {
|
|
|
if Task.isCancelled { return }
|
|
|
|
|
|
- criteria = progress.interpreted.criteria
|
|
|
- results = progress.listings
|
|
|
+ applyCriteriaFromPipeline(progress.interpreted.criteria)
|
|
|
+ applyProgressToResults(progress)
|
|
|
totalResultCount = progress.totalEstimated ?? progress.listings.count
|
|
|
isLoadingMore = progress.isLoadingMore
|
|
|
|
|
|
@@ -138,6 +140,11 @@ final class JobSearchViewModel {
|
|
|
state = .empty
|
|
|
}
|
|
|
// else: still loading primary/enrichment with no rows yet
|
|
|
+
|
|
|
+ // Yield so SwiftUI can paint web_search rows before scrape resumes.
|
|
|
+ if progress.phase == .partial {
|
|
|
+ await Task.yield()
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
if Task.isCancelled { return }
|
|
|
@@ -146,6 +153,7 @@ final class JobSearchViewModel {
|
|
|
state = results.isEmpty ? .empty : .success
|
|
|
}
|
|
|
} catch is CancellationError {
|
|
|
+ isLoadingMore = false
|
|
|
return
|
|
|
} catch {
|
|
|
isLoadingMore = false
|
|
|
@@ -167,4 +175,37 @@ final class JobSearchViewModel {
|
|
|
func retry() {
|
|
|
submitSearch()
|
|
|
}
|
|
|
+
|
|
|
+ /// Updates filters from search interpretation without kicking off a second fetch.
|
|
|
+ private func applyCriteriaFromPipeline(_ newCriteria: SearchCriteria) {
|
|
|
+ guard newCriteria != criteria else { return }
|
|
|
+ suppressFilterRefresh = true
|
|
|
+ criteria = newCriteria
|
|
|
+ Task { @MainActor in
|
|
|
+ // Cover FiltersPanel onChange + its 350ms debounce so interpretation
|
|
|
+ // does not start a second search on top of enrichment.
|
|
|
+ try? await Task.sleep(nanoseconds: 500_000_000)
|
|
|
+ suppressFilterRefresh = false
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /// Partial = show web_search immediately. Complete = keep those rows and append scrape-only jobs.
|
|
|
+ private func applyProgressToResults(_ progress: JobSearchPipelineProgress) {
|
|
|
+ switch progress.phase {
|
|
|
+ case .partial:
|
|
|
+ results = progress.listings
|
|
|
+ case .complete:
|
|
|
+ guard !results.isEmpty, isLoadingMore else {
|
|
|
+ results = progress.listings
|
|
|
+ return
|
|
|
+ }
|
|
|
+ let existingKeys = Set(results.map(\.indeedJobKey))
|
|
|
+ let appended = progress.listings.filter { !existingKeys.contains($0.indeedJobKey) }
|
|
|
+ if appended.isEmpty {
|
|
|
+ // Scrape overlapped fully — keep what the user already sees.
|
|
|
+ return
|
|
|
+ }
|
|
|
+ results.append(contentsOf: appended)
|
|
|
+ }
|
|
|
+ }
|
|
|
}
|