feat: offline article caching + smart pull-to-refresh reshuffle
Some checks failed
CI / Build · macos-14 · Debug (push) Has been cancelled
CI / Build · macos-15 · Debug (push) Has been cancelled
CI / Build · macos-14 · Release (push) Has been cancelled
CI / Build · macos-15 · Release (push) Has been cancelled

Offline caching:
- BackgroundRefreshManager.preCacheArticles() fetches top article per story
  (score ≥60, up to 20 stories) and stores as CachedArticle in SwiftData
- Runs after every BG fetch AND after every foreground sync via StoryStore
- Articles are available in ArticleReaderView offline without ever opening them

Pull-to-refresh reshuffle when nothing new:
- loadStories(refresh:) now returns newCount (genuinely new story IDs)
- If server returns 0 new stories, feed merges in any BG-cached stories not
  already in the live feed, then re-sorts by signal score (reshuffle)
- seenSnapshot is always cleared so everything re-ranks fresh

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Robin Kutesa
2026-06-21 03:01:44 +03:00
parent a1d7979048
commit 6677033faa
3 changed files with 68 additions and 12 deletions

View File

@@ -51,15 +51,14 @@ final class StoryStore: ObservableObject {
// MARK: - Load
func loadStories(refresh: Bool = false) async {
guard !isLoading else { return }
@discardableResult
func loadStories(refresh: Bool = false) async -> Int {
guard !isLoading else { return 0 }
isLoading = true
error = nil
// Don't wipe the visible feed up-front. Only the cursor resets for a
// refresh; the list is replaced on success. A cancelled/failed refresh
// then keeps showing what we already have instead of blanking out.
let cursor = refresh ? nil : nextCursor
var newCount = 0
do {
let result = try await api.fetchStories(
@@ -67,17 +66,38 @@ final class StoryStore: ObservableObject {
topic: selectedTopic,
tags: selectedTags
)
stories = refresh ? result.data : stories + result.data
if refresh {
let existingIds = Set(stories.map(\.id))
newCount = result.data.filter { !existingIds.contains($0.id) }.count
stories = result.data
} else {
stories += result.data
newCount = result.data.count
}
nextCursor = result.nextCursor
hasMore = result.hasMore
lastSyncedAt = Date()
preCacheArticlesInBackground()
} catch let e as APIError where !e.isCancelled {
error = e
} catch {
// Benign cancellation (network blip / superseded request): keep state.
}
} catch {}
isLoading = false
return newCount
}
private func preCacheArticlesInBackground() {
guard let container = BackgroundRefreshManager.container else { return }
let snapshot = stories
Task.detached { await BackgroundRefreshManager.preCacheArticles(snapshot, container: container) }
}
/// Merge cached stories into the live feed (used when pull-refresh finds nothing new).
func mergeStories(_ extra: [StorySummary]) async {
let existing = Set(stories.map(\.id))
let novel = extra.filter { !existing.contains($0.id) }
guard !novel.isEmpty else { return }
stories = (stories + novel).sorted(by: StorySummary.feedOrder)
}
func loadMore() async {