Stabilize feed order with a deterministic signal-score tiebreaker
Equal-score stories reshuffled on every pull-to-refresh because the sort was unstable. Add StorySummary.feedOrder (signalScore desc, then id desc, mirroring the backend's signal_score DESC, id DESC) and use it for the feed, the WebSocket re-sort, and the Saved/Search tabs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -139,7 +139,7 @@ final class StoryStore: ObservableObject {
|
|||||||
createdAt: old.createdAt
|
createdAt: old.createdAt
|
||||||
)
|
)
|
||||||
stories[idx] = updated
|
stories[idx] = updated
|
||||||
stories.sort { $0.signalScore > $1.signalScore }
|
stories.sort(by: StorySummary.feedOrder)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func removeStory(id: String) {
|
private func removeStory(id: String) {
|
||||||
|
|||||||
@@ -34,12 +34,12 @@ struct SignalFeedView: View {
|
|||||||
/// Live stories when online; cached fallback when the feed is empty & offline.
|
/// Live stories when online; cached fallback when the feed is empty & offline.
|
||||||
private var displayStories: [StorySummary] {
|
private var displayStories: [StorySummary] {
|
||||||
if !store.stories.isEmpty {
|
if !store.stories.isEmpty {
|
||||||
return store.stories.sorted { $0.signalScore > $1.signalScore }
|
return store.stories.sorted(by: StorySummary.feedOrder)
|
||||||
}
|
}
|
||||||
if !ws.connectionState.isLive {
|
if !ws.connectionState.isLive {
|
||||||
return cachedStories
|
return cachedStories
|
||||||
.map(StorySummary.init(cached:))
|
.map(StorySummary.init(cached:))
|
||||||
.sorted { $0.signalScore > $1.signalScore }
|
.sorted(by: StorySummary.feedOrder)
|
||||||
}
|
}
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ struct SavedView: View {
|
|||||||
@Query private var readStories: [ReadStory]
|
@Query private var readStories: [ReadStory]
|
||||||
|
|
||||||
private var stories: [StorySummary] {
|
private var stories: [StorySummary] {
|
||||||
cachedStories.map(StorySummary.init(cached:)).sorted { $0.signalScore > $1.signalScore }
|
cachedStories.map(StorySummary.init(cached:)).sorted(by: StorySummary.feedOrder)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -133,7 +133,7 @@ struct SearchView: View {
|
|||||||
return store.stories
|
return store.stories
|
||||||
.filter { $0.headline.localizedCaseInsensitiveContains(query)
|
.filter { $0.headline.localizedCaseInsensitiveContains(query)
|
||||||
|| $0.summary.localizedCaseInsensitiveContains(query) }
|
|| $0.summary.localizedCaseInsensitiveContains(query) }
|
||||||
.sorted { $0.signalScore > $1.signalScore }
|
.sorted(by: StorySummary.feedOrder)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
|||||||
@@ -160,3 +160,14 @@ struct ArticleRoute: Hashable {
|
|||||||
let storyId: String
|
let storyId: String
|
||||||
let parentHeadline: String
|
let parentHeadline: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Stable feed ordering
|
||||||
|
|
||||||
|
extension StorySummary {
|
||||||
|
/// Deterministic ranking: signal score descending, then id descending — the
|
||||||
|
/// same order the backend uses (`signal_score DESC, id DESC`). Without the
|
||||||
|
/// id tiebreaker, equal-score stories reshuffle on every (unstable) re-sort.
|
||||||
|
static func feedOrder(_ a: StorySummary, _ b: StorySummary) -> Bool {
|
||||||
|
a.signalScore != b.signalScore ? a.signalScore > b.signalScore : a.id > b.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user