// 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 private var nextCursor: String? private var refreshTask: Task? private var cancellables = Set() private let ws = WebSocketManager.shared private let api = APIClient.shared private init() { subscribeToWebSocket() } // MARK: - Load func loadStories(refresh: Bool = false) async { guard !isLoading else { return } 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 do { let result = try await api.fetchStories( cursor: cursor, topic: selectedTopic ) stories = refresh ? result.data : stories + result.data nextCursor = result.nextCursor hasMore = result.hasMore lastSyncedAt = Date() } catch let e as APIError where !e.isCancelled { error = e } catch { // Benign cancellation (network blip / superseded request): keep state. } isLoading = false } func loadMore() async { guard hasMore, !isLoadingMore, let cursor = nextCursor else { return } isLoadingMore = true do { let result = try await api.fetchStories(cursor: cursor, topic: selectedTopic) 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 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, 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 { $0.signalScore > $1.signalScore } } 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") }