// 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 = [] 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 private init() { 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 { 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 {} 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) stories += result.data nextCursor = result.nextCursor hasMore = result.hasMore } catch {} isLoadingMore = false } /// Coalesce bursts of `story.created` events into a single refresh so a busy /// feed (47 sources) doesn't fire a refetch per story. 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?.loadStories(refresh: true) } } 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) } // 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") }