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()
// Pre-cache articles for offline reading
await preCacheArticles(page.data, container: container)
if !newStories.isEmpty {
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
// Slugs for the featured categories the user cares about.