From eec610024c078ebffacd1c9eb131809ed7248173 Mon Sep 17 00:00:00 2001 From: Robin Kutesa Date: Sun, 21 Jun 2026 01:58:51 +0300 Subject: [PATCH] feat: read-shelf auto-expand, stripe removal, pull-reshuffle, BG notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- Jarvis/Store/BackgroundRefreshManager.swift | 49 ++++++++++++++++++++- Jarvis/Views/Home/SignalFeedView.swift | 7 ++- Jarvis/Views/Home/StoryRowView.swift | 8 +++- 3 files changed, 60 insertions(+), 4 deletions(-) diff --git a/Jarvis/Store/BackgroundRefreshManager.swift b/Jarvis/Store/BackgroundRefreshManager.swift index 0f59276..5e5a4cc 100644 --- a/Jarvis/Store/BackgroundRefreshManager.swift +++ b/Jarvis/Store/BackgroundRefreshManager.swift @@ -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(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 = [ + "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() { diff --git a/Jarvis/Views/Home/SignalFeedView.swift b/Jarvis/Views/Home/SignalFeedView.swift index d65a85d..8e604d3 100644 --- a/Jarvis/Views/Home/SignalFeedView.swift +++ b/Jarvis/Views/Home/SignalFeedView.swift @@ -272,9 +272,13 @@ struct SignalFeedView: View { .scrollContentBackground(.hidden) .background(Color.black) .animation(.snappy, value: showRead) + .animation(.snappy, value: readStories.count) .onAppear { captureSeenSnapshot() } .refreshable { - captureSeenSnapshot() // sink what you've already seen + // Reshuffle: clear the seen snapshot so all stories re-rank by signal + // score, and any stories cached by background refresh float to the top. + seenSnapshot = [] + seenTracker.ids = [] await store.loadStories(refresh: true) } } @@ -566,6 +570,7 @@ struct SignalFeedView: View { if existing?.first == nil { modelContext.insert(ReadStory(id: id)) try? modelContext.save() + withAnimation(.snappy) { showRead = true } } } diff --git a/Jarvis/Views/Home/StoryRowView.swift b/Jarvis/Views/Home/StoryRowView.swift index 038152f..4bec2e0 100644 --- a/Jarvis/Views/Home/StoryRowView.swift +++ b/Jarvis/Views/Home/StoryRowView.swift @@ -41,7 +41,12 @@ struct StoryRowView: View { private var fullBody: some View { HStack(alignment: .top, spacing: 0) { - SignalStripe(score: story.signalScore, muted: isRead) + if isRead { + // No stripe for read stories — remove the orange signal indicator entirely. + Color.clear.frame(width: 3) + } else { + SignalStripe(score: story.signalScore) + } VStack(alignment: .leading, spacing: 9) { // Title (+ cached dot) @@ -90,7 +95,6 @@ struct StoryRowView: View { .padding(.trailing, 16) .padding(.top, 16) } - .opacity(isRead ? 0.55 : 1) .frame(maxWidth: .infinity, alignment: .leading) .background(Color.black) .overlay(alignment: .bottom) {