From a1d7979048b12174df1375393e8281ad037692ff Mon Sep 17 00:00:00 2001 From: Robin Kutesa Date: Sun, 21 Jun 2026 02:57:54 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20automatic=20foreground=20sync=20?= =?UTF-8?q?=E2=80=94=205=20min=20timer=20+=20resume=20refresh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StoryStore gains startForegroundSync() / stopForegroundSync(): - Polls loadStories(refresh:true) every 5 minutes while app is active - WebSocket handles real-time storyCreated events between polls - Timer cancels when app goes to background to save battery JarvisApp wires scenePhase: - .active → start 5-min timer; immediate refresh if last sync > 2 min ago - .background → cancel timer - On launch → timer starts after setup completes Net result: feed replenishes automatically; user never hits an empty list. Co-Authored-By: Claude Sonnet 4.6 --- Jarvis/JarvisApp.swift | 17 +++++++++++++++++ Jarvis/Store/StoryStore.swift | 35 +++++++++++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/Jarvis/JarvisApp.swift b/Jarvis/JarvisApp.swift index 5766698..dd76a78 100644 --- a/Jarvis/JarvisApp.swift +++ b/Jarvis/JarvisApp.swift @@ -82,6 +82,7 @@ struct JarvisApp: App { @StateObject private var connectivity = ConnectivitySettings.shared @StateObject private var connManager = ConnectivityManager.shared @StateObject private var notifications = NotificationManager.shared + @Environment(\.scenePhase) private var scenePhase @State private var splashDone = false // Single ModelContainer instance shared with BackgroundRefreshManager. @@ -120,6 +121,22 @@ struct JarvisApp: App { connManager.start() await connManager.resolveAndActivate() await notifications.bootstrap() + store.startForegroundSync() + } + .onChange(of: scenePhase) { _, phase in + switch phase { + case .active: + store.startForegroundSync() + // Refresh immediately if we've been away more than 2 minutes. + if let last = store.lastSyncedAt, + Date().timeIntervalSince(last) > 120 { + Task { await store.loadStories(refresh: true) } + } + case .background: + store.stopForegroundSync() + default: + break + } } } .modelContainer(container) diff --git a/Jarvis/Store/StoryStore.swift b/Jarvis/Store/StoryStore.swift index 7f8bb70..4bdbb53 100644 --- a/Jarvis/Store/StoryStore.swift +++ b/Jarvis/Store/StoryStore.swift @@ -16,10 +16,32 @@ final class StoryStore: ObservableObject { @Published var lastSyncedAt: Date? @Published var hasMore = false @Published var selectedTopic: String? = nil + @Published var selectedTags: Set = [] private var nextCursor: String? private var refreshTask: Task? + private var foregroundSyncTask: Task? private var cancellables = Set() + + // MARK: - Foreground sync + + /// Call when app becomes active. Polls every 5 min so the feed never runs dry. + /// The WebSocket handles real-time events between polls. + func startForegroundSync() { + foregroundSyncTask?.cancel() + foregroundSyncTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 5 * 60 * 1_000_000_000) + guard !Task.isCancelled else { return } + await self?.loadStories(refresh: true) + } + } + } + + func stopForegroundSync() { + foregroundSyncTask?.cancel() + foregroundSyncTask = nil + } private let ws = WebSocketManager.shared private let api = APIClient.shared @@ -42,7 +64,8 @@ final class StoryStore: ObservableObject { do { let result = try await api.fetchStories( cursor: cursor, - topic: selectedTopic + topic: selectedTopic, + tags: selectedTags ) stories = refresh ? result.data : stories + result.data nextCursor = result.nextCursor @@ -61,7 +84,7 @@ final class StoryStore: ObservableObject { guard hasMore, !isLoadingMore, let cursor = nextCursor else { return } isLoadingMore = true do { - let result = try await api.fetchStories(cursor: cursor, topic: selectedTopic) + let result = try await api.fetchStories(cursor: cursor, topic: selectedTopic, tags: selectedTags) stories += result.data nextCursor = result.nextCursor hasMore = result.hasMore @@ -82,6 +105,14 @@ final class StoryStore: ObservableObject { func setTopic(_ topic: String?) async { selectedTopic = topic + selectedTags = [] + await loadStories(refresh: true) + } + + func setPill(_ pill: StoryPill) async { + selectedTopic = nil + selectedTags = pill.slugs + nextCursor = nil await loadStories(refresh: true) }