From 9662c2abf64bc78e4b852d0431b0d74281c28e84 Mon Sep 17 00:00:00 2001 From: Robin Kutesa Date: Thu, 18 Jun 2026 23:33:22 +0300 Subject: [PATCH] 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 --- Jarvis/Store/StoryStore.swift | 2 +- Jarvis/Views/Home/SignalFeedView.swift | 4 ++-- Jarvis/Views/SecondaryTabs.swift | 4 ++-- Jarvis/Views/Shared/Theme.swift | 11 +++++++++++ 4 files changed, 16 insertions(+), 5 deletions(-) diff --git a/Jarvis/Store/StoryStore.swift b/Jarvis/Store/StoryStore.swift index 62069e4..8681431 100644 --- a/Jarvis/Store/StoryStore.swift +++ b/Jarvis/Store/StoryStore.swift @@ -139,7 +139,7 @@ final class StoryStore: ObservableObject { createdAt: old.createdAt ) stories[idx] = updated - stories.sort { $0.signalScore > $1.signalScore } + stories.sort(by: StorySummary.feedOrder) } private func removeStory(id: String) { diff --git a/Jarvis/Views/Home/SignalFeedView.swift b/Jarvis/Views/Home/SignalFeedView.swift index 368fddf..fd7f177 100644 --- a/Jarvis/Views/Home/SignalFeedView.swift +++ b/Jarvis/Views/Home/SignalFeedView.swift @@ -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 [] } diff --git a/Jarvis/Views/SecondaryTabs.swift b/Jarvis/Views/SecondaryTabs.swift index 62da8d3..eee4da1 100644 --- a/Jarvis/Views/SecondaryTabs.swift +++ b/Jarvis/Views/SecondaryTabs.swift @@ -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 { diff --git a/Jarvis/Views/Shared/Theme.swift b/Jarvis/Views/Shared/Theme.swift index cc7aa65..364645b 100644 --- a/Jarvis/Views/Shared/Theme.swift +++ b/Jarvis/Views/Shared/Theme.swift @@ -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 + } +}