2026-06-18 18:04:59 +03:00
|
|
|
// 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
|
2026-06-21 02:57:54 +03:00
|
|
|
@Published var selectedTags: Set<String> = []
|
2026-06-22 02:33:33 +03:00
|
|
|
@Published var sectionSupplement: [String: [StorySummary]] = [:]
|
2026-06-18 18:04:59 +03:00
|
|
|
|
|
|
|
|
private var nextCursor: String?
|
2026-06-18 23:22:56 +03:00
|
|
|
private var refreshTask: Task<Void, Never>?
|
2026-06-21 02:57:54 +03:00
|
|
|
private var foregroundSyncTask: Task<Void, Never>?
|
2026-06-18 18:04:59 +03:00
|
|
|
private var cancellables = Set<AnyCancellable>()
|
2026-06-21 02:57:54 +03:00
|
|
|
|
2026-06-22 02:33:33 +03:00
|
|
|
// 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<CachedStory>()) {
|
|
|
|
|
let sorted = cached.map(StorySummary.init(cached:)).sorted(by: StorySummary.feedOrder)
|
|
|
|
|
if !sorted.isEmpty { stories = sorted }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-21 02:57:54 +03:00
|
|
|
// MARK: - Foreground sync
|
|
|
|
|
|
2026-06-22 02:33:33 +03:00
|
|
|
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.
|
2026-06-21 02:57:54 +03:00
|
|
|
func startForegroundSync() {
|
|
|
|
|
foregroundSyncTask?.cancel()
|
|
|
|
|
foregroundSyncTask = Task { [weak self] in
|
|
|
|
|
while !Task.isCancelled {
|
2026-06-22 02:33:33 +03:00
|
|
|
try? await Task.sleep(nanoseconds: 90 * 1_000_000_000)
|
2026-06-21 02:57:54 +03:00
|
|
|
guard !Task.isCancelled else { return }
|
2026-06-22 02:33:33 +03:00
|
|
|
await self?.loadFeedFull()
|
2026-06-21 02:57:54 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func stopForegroundSync() {
|
|
|
|
|
foregroundSyncTask?.cancel()
|
|
|
|
|
foregroundSyncTask = nil
|
|
|
|
|
}
|
2026-06-18 18:04:59 +03:00
|
|
|
private let ws = WebSocketManager.shared
|
|
|
|
|
private let api = APIClient.shared
|
|
|
|
|
|
|
|
|
|
private init() {
|
2026-06-22 02:33:33 +03:00
|
|
|
restoreQuickCache() // synchronous — stories are ready before first render
|
2026-06-18 18:04:59 +03:00
|
|
|
subscribeToWebSocket()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// MARK: - Load
|
|
|
|
|
|
2026-06-21 03:01:44 +03:00
|
|
|
@discardableResult
|
|
|
|
|
func loadStories(refresh: Bool = false) async -> Int {
|
|
|
|
|
guard !isLoading else { return 0 }
|
2026-06-18 18:04:59 +03:00
|
|
|
isLoading = true
|
|
|
|
|
error = nil
|
|
|
|
|
|
2026-06-18 23:22:56 +03:00
|
|
|
let cursor = refresh ? nil : nextCursor
|
2026-06-21 03:01:44 +03:00
|
|
|
var newCount = 0
|
2026-06-18 18:04:59 +03:00
|
|
|
|
|
|
|
|
do {
|
|
|
|
|
let result = try await api.fetchStories(
|
2026-06-18 23:22:56 +03:00
|
|
|
cursor: cursor,
|
2026-06-21 02:57:54 +03:00
|
|
|
topic: selectedTopic,
|
|
|
|
|
tags: selectedTags
|
2026-06-18 18:04:59 +03:00
|
|
|
)
|
2026-06-21 03:01:44 +03:00
|
|
|
if refresh {
|
2026-06-22 02:33:33 +03:00
|
|
|
// Replace list; count only genuinely new IDs for reshuffle logic.
|
2026-06-21 03:01:44 +03:00
|
|
|
let existingIds = Set(stories.map(\.id))
|
|
|
|
|
newCount = result.data.filter { !existingIds.contains($0.id) }.count
|
|
|
|
|
stories = result.data
|
|
|
|
|
} else {
|
2026-06-22 02:33:33 +03:00
|
|
|
// 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
|
2026-06-21 03:01:44 +03:00
|
|
|
}
|
2026-06-18 18:04:59 +03:00
|
|
|
nextCursor = result.nextCursor
|
|
|
|
|
hasMore = result.hasMore
|
|
|
|
|
lastSyncedAt = Date()
|
2026-06-22 02:33:33 +03:00
|
|
|
persistQuickCache() // survives the next process kill
|
2026-06-21 03:01:44 +03:00
|
|
|
preCacheArticlesInBackground()
|
2026-06-18 23:22:56 +03:00
|
|
|
} catch let e as APIError where !e.isCancelled {
|
2026-06-18 18:04:59 +03:00
|
|
|
error = e
|
2026-06-22 02:33:33 +03:00
|
|
|
print("[StoryStore] fetch failed: \(e.localizedDescription)")
|
|
|
|
|
} catch {
|
|
|
|
|
print("[StoryStore] unexpected fetch error: \(error)")
|
|
|
|
|
}
|
2026-06-18 18:04:59 +03:00
|
|
|
|
|
|
|
|
isLoading = false
|
2026-06-21 03:01:44 +03:00
|
|
|
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)
|
2026-06-18 18:04:59 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func loadMore() async {
|
|
|
|
|
guard hasMore, !isLoadingMore, let cursor = nextCursor else { return }
|
|
|
|
|
isLoadingMore = true
|
|
|
|
|
do {
|
2026-06-21 02:57:54 +03:00
|
|
|
let result = try await api.fetchStories(cursor: cursor, topic: selectedTopic, tags: selectedTags)
|
2026-06-22 02:33:33 +03:00
|
|
|
// 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
|
2026-06-18 18:04:59 +03:00
|
|
|
nextCursor = result.nextCursor
|
|
|
|
|
hasMore = result.hasMore
|
2026-06-22 02:33:33 +03:00
|
|
|
} catch {
|
|
|
|
|
print("[StoryStore] loadMore error: \(error)")
|
|
|
|
|
}
|
2026-06-18 18:04:59 +03:00
|
|
|
isLoadingMore = false
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 02:33:33 +03:00
|
|
|
/// Coalesce bursts of `story.created` WS events — waits 3 s then does a
|
|
|
|
|
/// full paginated load so stories on page 2+ are captured.
|
2026-06-18 23:22:56 +03:00
|
|
|
private func scheduleCoalescedRefresh() {
|
|
|
|
|
refreshTask?.cancel()
|
|
|
|
|
refreshTask = Task { [weak self] in
|
|
|
|
|
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
|
|
|
|
guard !Task.isCancelled else { return }
|
2026-06-22 02:33:33 +03:00
|
|
|
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<String>)] = [
|
|
|
|
|
("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 }
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-18 23:22:56 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 18:04:59 +03:00
|
|
|
func setTopic(_ topic: String?) async {
|
|
|
|
|
selectedTopic = topic
|
2026-06-21 02:57:54 +03:00
|
|
|
selectedTags = []
|
2026-06-22 02:33:33 +03:00
|
|
|
await loadFeedFull()
|
2026-06-21 02:57:54 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func setPill(_ pill: StoryPill) async {
|
|
|
|
|
selectedTopic = nil
|
|
|
|
|
selectedTags = pill.slugs
|
|
|
|
|
nextCursor = nil
|
2026-06-22 02:33:33 +03:00
|
|
|
// 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()
|
2026-06-18 18:04:59 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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:
|
2026-06-18 23:22:56 +03:00
|
|
|
scheduleCoalescedRefresh()
|
2026-06-18 18:04:59 +03:00
|
|
|
|
|
|
|
|
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,
|
Filter pills by backend tags (slug membership), drop keyword matching
Replace the keyword/source-majority Interest system with a StoryPill enum that
matches stories by canonical category slugs. StorySummary gains a tolerant
tags[] (optional, decode-safe); effectiveTags uses tags, falling back to topic
during the backend transition. No more headline keyword-searching on the client —
the backend decides classification; the client asks slug membership.
Pills: All, F1, Sport, AI, Cloud, HomeLab, Tech, Uganda, South Africa, Canada, US.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 21:55:57 +03:00
|
|
|
tags: old.tags,
|
2026-06-18 18:04:59 +03:00
|
|
|
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
|
2026-06-18 23:33:22 +03:00
|
|
|
stories.sort(by: StorySummary.feedOrder)
|
2026-06-18 18:04:59 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
}
|