// BackgroundRefreshManager.swift // Jarvis — background story cache refresh. Fires via BGAppRefreshTask every ~30 min, // fetches the latest feed, and upserts CachedStory rows so the SwiftData cache is // warm before the user opens the app. import Foundation import BackgroundTasks import SwiftData import UserNotifications let jarvisFeedRefreshID = "com.kisani.jervis.feed.refresh" enum BackgroundRefreshManager { // Injected once from JarvisApp so the BG task can open a context on the same store. static var container: ModelContainer? // MARK: - Task handler static func handleFeedRefresh(task: BGAppRefreshTask) { // Reschedule first — if the task is killed we still get another run later. scheduleFeedRefresh() guard let container else { task.setTaskCompleted(success: false) return } let work = Task { await fetchAndCache(container: container) } task.expirationHandler = { work.cancel() task.setTaskCompleted(success: false) } Task { _ = await work.result task.setTaskCompleted(success: !work.isCancelled) } } // MARK: - Core fetch + upsert private static func fetchAndCache(container: ModelContainer) async { do { let page = try await APIClient.shared.fetchStories(limit: 40) let context = ModelContext(container) var newStories: [StorySummary] = [] for story in page.data { let id = story.id if let existing = (try? context.fetch( FetchDescriptor(predicate: #Predicate { $0.id == id })))?.first { existing.signalScore = story.signalScore existing.sourceCount = story.sourceCount existing.updatedAt = story.updatedAt } else { context.insert(CachedStory(from: story)) newStories.append(story) } } try? context.save() // Pre-cache articles for offline reading await preCacheArticles(page.data, container: container) if !newStories.isEmpty { notifyNewStories(newStories) } } catch { // Network unavailable in background — fail silently, next run will catch up. } } // 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. private static let featuredSlugs: Set = [ "artificial-intelligence", "machine-learning", "robotics", "technology", "cybersecurity", "security", "privacy-and-data-protection", "programming-and-software-development", "cloud-computing", "web-design-and-ui-ux", "homelab", ] private static let minSignal = 65 private static func notifyNewStories(_ stories: [StorySummary]) { // Best overall story above threshold. let eligible = stories .filter { $0.signalScore >= minSignal } .sorted { $0.signalScore > $1.signalScore } guard let lead = eligible.first else { return } let tags = Set((lead.tags ?? []).isEmpty ? [lead.topic] : (lead.tags ?? [])) let isFeatured = !tags.isDisjoint(with: featuredSlugs) let content = UNMutableNotificationContent() content.title = isFeatured ? "📡 \(Topic.label(lead.topic))" : "📡 New signals" content.body = lead.headline if eligible.count > 1 { content.subtitle = "+\(eligible.count - 1) more new \(isFeatured ? "stories" : "high-signal stories")" } content.sound = .default let req = UNNotificationRequest( identifier: "feed.new.\(UUID().uuidString)", content: content, trigger: nil ) UNUserNotificationCenter.current().add(req) } // MARK: - Schedule static func scheduleFeedRefresh() { let req = BGAppRefreshTaskRequest(identifier: jarvisFeedRefreshID) // iOS enforces a minimum interval (~15 min); 30 min is a reasonable target. req.earliestBeginDate = Date(timeIntervalSinceNow: 30 * 60) try? BGTaskScheduler.shared.submit(req) } }