feat: read-shelf auto-expand, stripe removal, pull-reshuffle, BG notifications
- Mark-read now immediately expands the READ shelf so the story is visible - Orange signal stripe is fully removed (not dimmed) when isRead=true - Pull-to-refresh clears seen snapshot → full reshuffle by signal score - BackgroundRefreshManager fires a local notification for the top new high-signal story after each cache refresh; Tech/AI/HomeLab stories get their category in the notification title (≥65 signal threshold) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
import Foundation
|
||||
import BackgroundTasks
|
||||
import SwiftData
|
||||
import UserNotifications
|
||||
|
||||
let jarvisFeedRefreshID = "com.kisani.jarvis.feed.refresh"
|
||||
|
||||
@@ -47,25 +48,71 @@ enum BackgroundRefreshManager {
|
||||
let page = try await APIClient.shared.fetchStories(limit: 40)
|
||||
let context = ModelContext(container)
|
||||
|
||||
var newStories: [StorySummary] = []
|
||||
|
||||
for story in page.data {
|
||||
let id = story.id
|
||||
if let existing = (try? context.fetch(
|
||||
FetchDescriptor<CachedStory>(predicate: #Predicate { $0.id == id })))?.first {
|
||||
// Refresh signal score and freshness on already-cached rows.
|
||||
existing.signalScore = story.signalScore
|
||||
existing.sourceCount = story.sourceCount
|
||||
existing.updatedAt = story.updatedAt
|
||||
} else {
|
||||
context.insert(CachedStory(from: story))
|
||||
newStories.append(story)
|
||||
}
|
||||
}
|
||||
|
||||
try? context.save()
|
||||
|
||||
if !newStories.isEmpty {
|
||||
notifyNewStories(newStories)
|
||||
}
|
||||
} catch {
|
||||
// Network unavailable in background — fail silently, next run will catch up.
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - New-story notifications
|
||||
|
||||
// Slugs for the featured categories the user cares about.
|
||||
private static let featuredSlugs: Set<String> = [
|
||||
"artificial-intelligence", "machine-learning", "robotics",
|
||||
"technology", "cybersecurity", "security", "privacy-and-data-protection",
|
||||
"programming-and-software-development", "cloud-computing", "web-design-and-ui-ux",
|
||||
"homelab",
|
||||
]
|
||||
|
||||
private static let minSignal = 65
|
||||
|
||||
private static func notifyNewStories(_ stories: [StorySummary]) {
|
||||
// Best overall story above threshold.
|
||||
let eligible = stories
|
||||
.filter { $0.signalScore >= minSignal }
|
||||
.sorted { $0.signalScore > $1.signalScore }
|
||||
guard let lead = eligible.first else { return }
|
||||
|
||||
let tags = Set((lead.tags ?? []).isEmpty ? [lead.topic] : (lead.tags ?? []))
|
||||
let isFeatured = !tags.isDisjoint(with: featuredSlugs)
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = isFeatured
|
||||
? "📡 \(Topic.label(lead.topic))"
|
||||
: "📡 New signals"
|
||||
content.body = lead.headline
|
||||
if eligible.count > 1 {
|
||||
content.subtitle = "+\(eligible.count - 1) more new \(isFeatured ? "stories" : "high-signal stories")"
|
||||
}
|
||||
content.sound = .default
|
||||
|
||||
let req = UNNotificationRequest(
|
||||
identifier: "feed.new.\(UUID().uuidString)",
|
||||
content: content,
|
||||
trigger: nil
|
||||
)
|
||||
UNUserNotificationCenter.current().add(req)
|
||||
}
|
||||
|
||||
// MARK: - Schedule
|
||||
|
||||
static func scheduleFeedRefresh() {
|
||||
|
||||
Reference in New Issue
Block a user