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:
Robin Kutesa
2026-06-18 23:33:22 +03:00
parent 1e582f5120
commit 9662c2abf6
4 changed files with 16 additions and 5 deletions

View File

@@ -34,12 +34,12 @@ struct SignalFeedView: View {
/// Live stories when online; cached fallback when the feed is empty & offline.
private var displayStories: [StorySummary] {
if !store.stories.isEmpty {
return store.stories.sorted { $0.signalScore > $1.signalScore }
return store.stories.sorted(by: StorySummary.feedOrder)
}
if !ws.connectionState.isLive {
return cachedStories
.map(StorySummary.init(cached:))
.sorted { $0.signalScore > $1.signalScore }
.sorted(by: StorySummary.feedOrder)
}
return []
}

View File

@@ -88,7 +88,7 @@ struct SavedView: View {
@Query private var readStories: [ReadStory]
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 {
@@ -133,7 +133,7 @@ struct SearchView: View {
return store.stories
.filter { $0.headline.localizedCaseInsensitiveContains(query)
|| $0.summary.localizedCaseInsensitiveContains(query) }
.sorted { $0.signalScore > $1.signalScore }
.sorted(by: StorySummary.feedOrder)
}
var body: some View {

View File

@@ -160,3 +160,14 @@ struct ArticleRoute: Hashable {
let storyId: 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
}
}