// StoryStore.swift // Jarvis — central observable store. Views read from here, never hit the API directly. import Foundation import Combine import SwiftData @MainActor final class StoryStore: ObservableObject { static let shared = StoryStore() @Published var stories: [StorySummary] = [] @Published var isLoading = false @Published var isLoadingMore = false @Published var error: APIError? @Published var lastSyncedAt: Date? @Published var hasMore = false @Published var selectedTopic: String? = nil @Published var selectedTags: Set = [] @Published var sectionSupplement: [String: [StorySummary]] = [:] private var nextCursor: String? private var refreshTask: Task? private var foregroundSyncTask: Task? private var cancellables = Set() // MARK: - Quick cache (UserDefaults — synchronous, no async gap) private let quickCacheKey = "jarvis.quickStories.v1" private let quickCacheMaxAge: TimeInterval = 84 * 3600 // 3.5 days /// Restore the last-known feed from UserDefaults synchronously on init. /// This runs before the first SwiftUI render so the feed is never blank. private func restoreQuickCache() { guard let raw = UserDefaults.standard.data(forKey: quickCacheKey) else { return } let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 guard let payload = try? decoder.decode(QuickCachePayload.self, from: raw) else { return } // Discard if too stale — matches CacheMaintenance window. guard Date().timeIntervalSince(payload.savedAt) < quickCacheMaxAge else { UserDefaults.standard.removeObject(forKey: quickCacheKey) return } if !payload.stories.isEmpty { stories = payload.stories } } /// Persist the current feed to UserDefaults so it survives process kills. private func persistQuickCache() { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 let payload = QuickCachePayload(stories: Array(stories.prefix(100)), savedAt: Date()) if let data = try? encoder.encode(payload) { UserDefaults.standard.set(data, forKey: quickCacheKey) } } private struct QuickCachePayload: Codable { let stories: [StorySummary] let savedAt: Date } // MARK: - SwiftData bootstrap (secondary, async — covers first-ever launch before // the quick cache exists, e.g. app reinstall). func bootstrap(container: ModelContainer) { guard stories.isEmpty else { return } let ctx = ModelContext(container) if let cached = try? ctx.fetch(FetchDescriptor()) { let sorted = cached.map(StorySummary.init(cached:)).sorted(by: StorySummary.feedOrder) if !sorted.isEmpty { stories = sorted } } } // MARK: - Foreground sync private var isFetchingFull = false /// Load page 1 then paginate to ≤100 stories. /// Guarded against concurrent invocations and rate-limited to ≥30 s between /// fetches so rapid foreground/background cycles don't hammer the API. func loadFeedFull() async { guard !isFetchingFull else { return } // Rate-limit: if the last *successful* sync was < 30 s ago, skip. if let last = lastSyncedAt, Date().timeIntervalSince(last) < 30 { return } isFetchingFull = true defer { isFetchingFull = false } await loadStories(refresh: true) var pages = 0 while !Task.isCancelled, hasMore, stories.count < 100, pages < 4 { await loadMore() pages += 1 } loadSectionSupplements() } /// Call when app becomes active. Polls every 90 s so new stories surface fast. func startForegroundSync() { foregroundSyncTask?.cancel() foregroundSyncTask = Task { [weak self] in while !Task.isCancelled { try? await Task.sleep(nanoseconds: 90 * 1_000_000_000) guard !Task.isCancelled else { return } await self?.loadFeedFull() } } } func stopForegroundSync() { foregroundSyncTask?.cancel() foregroundSyncTask = nil } private let ws = WebSocketManager.shared private let api = APIClient.shared private init() { restoreQuickCache() // synchronous — stories are ready before first render subscribeToWebSocket() } // MARK: - Load @discardableResult func loadStories(refresh: Bool = false) async -> Int { guard !isLoading else { return 0 } isLoading = true error = nil let cursor = refresh ? nil : nextCursor var newCount = 0 do { let result = try await api.fetchStories( cursor: cursor, topic: selectedTopic, tags: selectedTags ) if refresh { // Replace list; count only genuinely new IDs for reshuffle logic. let existingIds = Set(stories.map(\.id)) newCount = result.data.filter { !existingIds.contains($0.id) }.count stories = result.data } else { // Append only IDs not already present — cursor can overlap. let existingIds = Set(stories.map(\.id)) let novel = result.data.filter { !existingIds.contains($0.id) } stories += novel newCount = novel.count } nextCursor = result.nextCursor hasMore = result.hasMore lastSyncedAt = Date() persistQuickCache() // survives the next process kill preCacheArticlesInBackground() } catch let e as APIError where !e.isCancelled { error = e print("[StoryStore] fetch failed: \(e.localizedDescription)") } catch { print("[StoryStore] unexpected fetch error: \(error)") } 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 { guard hasMore, !isLoadingMore, let cursor = nextCursor else { return } isLoadingMore = true do { let result = try await api.fetchStories(cursor: cursor, topic: selectedTopic, tags: selectedTags) // Deduplicate: cursor-based pages can overlap when stories are added mid-fetch. let existingIds = Set(stories.map(\.id)) let novel = result.data.filter { !existingIds.contains($0.id) } stories += novel nextCursor = result.nextCursor hasMore = result.hasMore } catch { print("[StoryStore] loadMore error: \(error)") } isLoadingMore = false } /// Coalesce bursts of `story.created` WS events — waits 3 s then does a /// full paginated load so stories on page 2+ are captured. private func scheduleCoalescedRefresh() { refreshTask?.cancel() refreshTask = Task { [weak self] in try? await Task.sleep(nanoseconds: 3_000_000_000) guard !Task.isCancelled else { return } await self?.loadFeedFull() } } // MARK: - Section supplements // Sections with regional or niche tags rarely rank in the global top-100, // so we fetch a small targeted pool for each sparse section and let // makeDigest() use it as a fallback. private let supplementSections: [(id: String, tags: Set)] = [ ("east-africa", ["east-africa"]), ("africa", ["africa"]), ("sport", ["sports", "esports", "formula-1"]), ("science", ["science", "astronomy"]), ("health", ["health", "health-and-wellness"]), ] func loadSectionSupplements() { guard selectedTags.isEmpty else { return } // only for "All" pill for sec in supplementSections { let id = sec.id let tags = sec.tags Task { [weak self] in guard let self else { return } if let result = try? await api.fetchStories(tags: tags, limit: 8) { await MainActor.run { self.sectionSupplement[id] = result.data } } } } } func setTopic(_ topic: String?) async { selectedTopic = topic selectedTags = [] await loadFeedFull() } func setPill(_ pill: StoryPill) async { selectedTopic = nil selectedTags = pill.slugs nextCursor = nil // Filter changed — must fetch regardless of rate-limit, otherwise // the old stories stay in place and the new pill finds almost nothing. lastSyncedAt = nil await loadFeedFull() } // MARK: - WebSocket private func subscribeToWebSocket() { ws.events .receive(on: DispatchQueue.main) .sink { [weak self] event in self?.handle(event: event) } .store(in: &cancellables) } private func handle(event: WSEvent) { switch event.type { case .storyUpdated: guard let id = event.storyId, let score = event.signalScore, let count = event.sourceCount else { return } updateStory(id: id, signalScore: score, sourceCount: count) Task { await refetch(storyId: id) } case .storyCreated: scheduleCoalescedRefresh() case .storyStale: guard let id = event.storyId else { return } removeStory(id: id) case .feedHealth: NotificationCenter.default.post(name: .feedHealthChanged, object: event) default: break } } private func updateStory(id: String, signalScore: Int, sourceCount: Int) { guard let idx = stories.firstIndex(where: { $0.id == id }) else { return } let old = stories[idx] let updated = StorySummary( id: old.id, headline: old.headline, summary: old.summary, topic: old.topic, tags: old.tags, signalScore: signalScore, scoreBreakdown: old.scoreBreakdown, sourceCount: sourceCount, sources: old.sources, consensus: old.consensus, conflict: old.conflict, updatedAt: old.updatedAt, createdAt: old.createdAt ) stories[idx] = updated stories.sort(by: StorySummary.feedOrder) } private func removeStory(id: String) { stories.removeAll { $0.id == id } } private func refetch(storyId: String) async { // Lightweight: only update what changed via WS patch above. // Full detail is fetched lazily when user taps into the story. } } extension Notification.Name { static let feedHealthChanged = Notification.Name("feedHealthChanged") }