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

@@ -65,6 +65,9 @@ enum BackgroundRefreshManager {
try? context.save() try? context.save()
// Pre-cache articles for offline reading
await preCacheArticles(page.data, container: container)
if !newStories.isEmpty { if !newStories.isEmpty {
notifyNewStories(newStories) notifyNewStories(newStories)
} }
@@ -73,6 +76,30 @@ enum BackgroundRefreshManager {
} }
} }
// MARK: - Article pre-caching
/// Fetch and store the top article for each high-signal uncached story so
/// the user can read them offline without having opened them first.
/// Safe to call in background or foreground creates its own ModelContext.
static func preCacheArticles(_ stories: [StorySummary], container: ModelContainer) async {
let context = ModelContext(container)
let top = stories
.filter { $0.signalScore >= 60 && !$0.sources.isEmpty }
.sorted { $0.signalScore > $1.signalScore }
.prefix(20)
for story in top {
let sid = story.id
let alreadyCached = (try? context.fetch(
FetchDescriptor<CachedArticle>(predicate: #Predicate { $0.storyId == sid })
))?.isEmpty == false
guard !alreadyCached, let src = story.sources.first else { continue }
guard let art = try? await APIClient.shared.fetchArticle(id: src.id) else { continue }
context.insert(CachedArticle(from: art))
try? context.save()
}
}
// MARK: - New-story notifications // MARK: - New-story notifications
// Slugs for the featured categories the user cares about. // Slugs for the featured categories the user cares about.

View File

@@ -51,15 +51,14 @@ final class StoryStore: ObservableObject {
// MARK: - Load // MARK: - Load
func loadStories(refresh: Bool = false) async { @discardableResult
guard !isLoading else { return } func loadStories(refresh: Bool = false) async -> Int {
guard !isLoading else { return 0 }
isLoading = true isLoading = true
error = nil 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 let cursor = refresh ? nil : nextCursor
var newCount = 0
do { do {
let result = try await api.fetchStories( let result = try await api.fetchStories(
@@ -67,17 +66,38 @@ final class StoryStore: ObservableObject {
topic: selectedTopic, topic: selectedTopic,
tags: selectedTags 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 nextCursor = result.nextCursor
hasMore = result.hasMore hasMore = result.hasMore
lastSyncedAt = Date() lastSyncedAt = Date()
preCacheArticlesInBackground()
} catch let e as APIError where !e.isCancelled { } catch let e as APIError where !e.isCancelled {
error = e error = e
} catch { } catch {}
// Benign cancellation (network blip / superseded request): keep state.
}
isLoading = false 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 { func loadMore() async {

View File

@@ -251,11 +251,20 @@ struct SignalFeedView: View {
.animation(.snappy, value: readStories.count) .animation(.snappy, value: readStories.count)
.onAppear { captureSeenSnapshot() } .onAppear { captureSeenSnapshot() }
.refreshable { .refreshable {
// Reshuffle: clear the seen snapshot so all stories re-rank by signal
// score, and any stories cached by background refresh float to the top.
seenSnapshot = [] seenSnapshot = []
seenTracker.ids = [] seenTracker.ids = []
await store.loadStories(refresh: true) let new = await store.loadStories(refresh: true)
// Nothing new from server pull in any stories cached by background
// refresh that aren't already in the live feed, then reshuffle.
if new == 0 {
let liveIds = Set(store.stories.map(\.id))
let extra = cachedStories
.filter { !liveIds.contains($0.id) }
.map(StorySummary.init(cached:))
if !extra.isEmpty {
await store.mergeStories(extra)
}
}
} }
} }