Files
jarvis/Jarvis/Store/StoryStore.swift
Robin Kutesa a1d7979048
Some checks failed
CI / Build · macos-14 · Debug (push) Has been cancelled
CI / Build · macos-15 · Debug (push) Has been cancelled
CI / Build · macos-14 · Release (push) Has been cancelled
CI / Build · macos-15 · Release (push) Has been cancelled
feat: automatic foreground sync — 5 min timer + resume refresh
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 <noreply@anthropic.com>
2026-06-21 02:57:54 +03:00

190 lines
5.9 KiB
Swift

// 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<String> = []
private var nextCursor: String?
private var refreshTask: Task<Void, Never>?
private var foregroundSyncTask: Task<Void, Never>?
private var cancellables = Set<AnyCancellable>()
// 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
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,
tags: selectedTags
)
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, 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")
}