diff --git a/Jarvis/JarvisApp.swift b/Jarvis/JarvisApp.swift index 7d77a1f..e0759a5 100644 --- a/Jarvis/JarvisApp.swift +++ b/Jarvis/JarvisApp.swift @@ -51,6 +51,7 @@ struct JarvisApp: App { await notifications.bootstrap() } } - .modelContainer(for: [CachedStory.self, CachedArticle.self, ReadStory.self, SavedStory.self]) + .modelContainer(for: [CachedStory.self, CachedArticle.self, ReadStory.self, + SavedStory.self, SeenStory.self]) } } diff --git a/Jarvis/Models/Models.swift b/Jarvis/Models/Models.swift index bd64738..a5b23f0 100644 --- a/Jarvis/Models/Models.swift +++ b/Jarvis/Models/Models.swift @@ -210,6 +210,17 @@ final class SavedStory { } } +@Model +final class SeenStory { + @Attribute(.unique) var id: String + var seenAt: Date + + init(id: String) { + self.id = id + self.seenAt = Date() + } +} + @Model final class CachedArticle { @Attribute(.unique) var id: String diff --git a/Jarvis/Views/Home/SignalFeedView.swift b/Jarvis/Views/Home/SignalFeedView.swift index bca44b2..1f7d6c3 100644 --- a/Jarvis/Views/Home/SignalFeedView.swift +++ b/Jarvis/Views/Home/SignalFeedView.swift @@ -25,10 +25,14 @@ struct SignalFeedView: View { @Query private var cachedArticles: [CachedArticle] @Query private var readStories: [ReadStory] @Query private var savedStories: [SavedStory] + @Query private var seenStories: [SeenStory] @State private var showFeeds = false @State private var showSettings = false @State private var selectedInterest: Interest? = nil @State private var shareStory: StorySummary? + /// Snapshot of already-seen story ids, captured per load so the order stays + /// stable while scrolling (and refreshes to sink newly-seen ones). + @State private var seenSnapshot: Set = [] /// Stories that have full article content cached → eligible for the green dot. private var cachedStoryIds: Set { Set(cachedArticles.map(\.storyId)) } @@ -45,16 +49,36 @@ struct SignalFeedView: View { } else { base = [] } - guard let interest = selectedInterest else { + + let filtered: [StorySummary] + if let interest = selectedInterest { + if interest.id == "f1" { + filtered = base.filter(interest.matches) + } else { + // Other pills (incl. Tech/Finance) exclude F1 (backend mis-tags it). + filtered = base.filter { interest.matches($0) && !Interest.f1.matches($0) } + } + } else { // "All" is news-only: F1 lives solely under its own pill. - return base.filter { !Interest.f1.matches($0) } + filtered = base.filter { !Interest.f1.matches($0) } } - if interest.id == "f1" { - return base.filter(interest.matches) - } - // Every other pill (incl. Tech/Finance) excludes F1, since the backend - // mis-tags F1 stories into finance/tech/politics. - return base.filter { interest.matches($0) && !Interest.f1.matches($0) } + + // Sink stories you've already read or seen (in a prior load) so fresh + // content surfaces and you stop seeing the same articles. Uses a per-load + // snapshot so the order is stable while you scroll. + let demoted = readStoryIds.union(seenSnapshot) + let fresh = filtered.filter { !demoted.contains($0.id) } + let old = filtered.filter { demoted.contains($0.id) } + return fresh + old + } + + private func captureSeenSnapshot() { seenSnapshot = Set(seenStories.map(\.id)) } + + private func markSeen(_ s: StorySummary) { + let id = s.id + guard !seenStories.contains(where: { $0.id == id }) else { return } + modelContext.insert(SeenStory(id: id)) + try? modelContext.save() } var body: some View { @@ -218,6 +242,7 @@ struct SignalFeedView: View { .listRowBackground(Color.black) .listRowSeparator(.hidden) .onAppear { + markSeen(story) if story.id == displayStories.last?.id { Task { await store.loadMore() } } @@ -255,7 +280,9 @@ struct SignalFeedView: View { .listStyle(.plain) .scrollContentBackground(.hidden) .background(Color.black) + .onAppear { captureSeenSnapshot() } .refreshable { + captureSeenSnapshot() // sink what you've already seen await store.loadStories(refresh: true) } } diff --git a/Jarvis/Views/Shared/Theme.swift b/Jarvis/Views/Shared/Theme.swift index 6be0986..bd3cf1b 100644 --- a/Jarvis/Views/Shared/Theme.swift +++ b/Jarvis/Views/Shared/Theme.swift @@ -188,25 +188,35 @@ struct Interest: Identifiable, Hashable { /// When set, matches the backend topic classification instead of keywords /// (e.g. finance, tech). var topicSlug: String? = nil - /// Whether to consider source/outlet names when matching. True only for F1 - /// (its sources are the signal). Regions must NOT — an outlet's location is - /// not the story's location (e.g. a Congo story carried by an SA outlet). - var matchSources: Bool = false - func matches(_ s: StorySummary) -> Bool { + if id == "f1" { return Interest.isFormula1(s) } if let topicSlug { return s.topic.caseInsensitiveCompare(topicSlug) == .orderedSame } - // Headline only — the backend's summaries are polluted by over-clustering - // (a Congo headline can carry a South-Africa summary), so they're unsafe - // to match on. F1 additionally trusts its (clean) source names. - var hay = s.headline - if matchSources { hay += " " + s.sources.map(\.name).joined(separator: " ") } - hay = hay.lowercased() + // Headline only — the backend's summaries/outlet names are polluted by + // over-clustering (a Congo headline can carry a South-Africa summary or an + // SA outlet), so they're unsafe to match on. + let hay = s.headline.lowercased() if excludeKeywords.contains(where: { hay.contains($0) }) { return false } return keywords.contains { hay.contains($0) } } + /// F1 = a clear F1 headline, or a majority of F1-feed sources. A single F1 + /// source isn't enough — the over-clustered backend sometimes attaches one to + /// an unrelated story. Avoids substring traps like "fia" matching "deFIAnt". + static func isFormula1(_ s: StorySummary) -> Bool { + let h = s.headline.lowercased() + if h.contains("formula 1") || h.contains("formula one") || h.contains("grand prix") + || h.hasPrefix("f1 ") || h.hasSuffix(" f1") || h.contains(" f1 ") || h.contains("f1:") { + return true + } + let feeds = ["f1", "racefans", "motorsport", "autosport", "planetf1", "the race", "formula 1"] + let srcs = s.sources + guard !srcs.isEmpty else { return false } + let n = srcs.filter { src in feeds.contains { src.name.lowercased().contains($0) } }.count + return Double(n) / Double(srcs.count) >= 0.5 + } + // Region keywords are place/person names found in the story text — never // outlet names (which are unreliable due to backend over-clustering). private static let ugandaKeywords = ["uganda", "ugandan", "kampala", "museveni", @@ -216,22 +226,12 @@ struct Interest: Identifiable, Hashable { "eskom", "gauteng", "western cape", "soweto", "drakensberg", "mbalula", "zuma"] - static let f1 = Interest(id: "f1", label: "F1", - keywords: ["formula 1", "formula one", "grand prix", "f1", "verstappen", "hamilton", - "mclaren", "ferrari", "red bull", "racefans", "motorsport", "autosport", - "pole position", "pirelli", "qualifying", "fia", "planetf1"], - matchSources: true) + static let f1 = Interest(id: "f1", label: "F1") // matching handled by isFormula1 - /// Pinned filter pills, in order. Regions are mutually exclusive. + /// Pinned filter pills, in order. static let pinned: [Interest] = [ f1, Interest(id: "uganda", label: "Uganda", keywords: ugandaKeywords), - Interest(id: "east-africa", label: "East Africa", - keywords: ["east africa", "eastafrican", "kenya", "kenyan", "nairobi", - "tanzania", "tanzanian", "dar es salaam", "rwanda", "rwandan", - "kigali", "ethiopia", "ethiopian", "addis ababa", "burundi", - "south sudan", "juba"], - excludeKeywords: ugandaKeywords + southAfricaKeywords), Interest(id: "south-africa", label: "South Africa", keywords: southAfricaKeywords), Interest(id: "finance", label: "Finance", topicSlug: "finance"), Interest(id: "tech", label: "Tech", topicSlug: "tech"),