From 6677033faadd3ac3e25aa80ddb62a1a2ddc713bc Mon Sep 17 00:00:00 2001 From: Robin Kutesa Date: Sun, 21 Jun 2026 03:01:44 +0300 Subject: [PATCH] feat: offline article caching + smart pull-to-refresh reshuffle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Jarvis/Store/BackgroundRefreshManager.swift | 27 +++++++++++++++ Jarvis/Store/StoryStore.swift | 38 ++++++++++++++++----- Jarvis/Views/Home/SignalFeedView.swift | 15 ++++++-- 3 files changed, 68 insertions(+), 12 deletions(-) diff --git a/Jarvis/Store/BackgroundRefreshManager.swift b/Jarvis/Store/BackgroundRefreshManager.swift index 5e5a4cc..1592abb 100644 --- a/Jarvis/Store/BackgroundRefreshManager.swift +++ b/Jarvis/Store/BackgroundRefreshManager.swift @@ -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(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. diff --git a/Jarvis/Store/StoryStore.swift b/Jarvis/Store/StoryStore.swift index 4bdbb53..cc1cf23 100644 --- a/Jarvis/Store/StoryStore.swift +++ b/Jarvis/Store/StoryStore.swift @@ -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 { diff --git a/Jarvis/Views/Home/SignalFeedView.swift b/Jarvis/Views/Home/SignalFeedView.swift index d456ef2..883aa6d 100644 --- a/Jarvis/Views/Home/SignalFeedView.swift +++ b/Jarvis/Views/Home/SignalFeedView.swift @@ -251,11 +251,20 @@ struct SignalFeedView: View { .animation(.snappy, value: readStories.count) .onAppear { captureSeenSnapshot() } .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 = [] 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) + } + } } }