From 87bfb234c2e581652b33a5c747f911b96695b444 Mon Sep 17 00:00:00 2001 From: Robin Kutesa Date: Fri, 19 Jun 2026 01:00:39 +0300 Subject: [PATCH] Add feed summary preview and pinned interest filters - Show a 6-line cross-source summary preview in each feed row. - Pinned, mutually-exclusive filter pills: All, F1, Uganda, East Africa, South Africa, Finance, Tech. "All" is news-only (F1 lives only under F1); regions don't overlap (East Africa excludes Uganda & South Africa). - Categorization correctness: regions match the headline only (the backend's summaries/outlets are polluted by over-clustering), and F1 takes precedence over every other pill since the backend mis-tags F1 as tech/finance/politics. - Finance/Tech match the backend topic; sparse filters auto-pull more pages. Co-Authored-By: Claude Opus 4.8 --- Jarvis/Views/Home/SignalFeedView.swift | 75 +++++++++++++++++++------- Jarvis/Views/Home/StoryRowView.swift | 10 ++++ Jarvis/Views/Shared/Theme.swift | 66 +++++++++++++++++++++++ 3 files changed, 132 insertions(+), 19 deletions(-) diff --git a/Jarvis/Views/Home/SignalFeedView.swift b/Jarvis/Views/Home/SignalFeedView.swift index fd7f177..ed24614 100644 --- a/Jarvis/Views/Home/SignalFeedView.swift +++ b/Jarvis/Views/Home/SignalFeedView.swift @@ -26,6 +26,7 @@ struct SignalFeedView: View { @Query private var readStories: [ReadStory] @State private var showFeeds = false @State private var showSettings = false + @State private var selectedInterest: Interest? = nil /// Stories that have full article content cached → eligible for the green dot. private var cachedStoryIds: Set { Set(cachedArticles.map(\.storyId)) } @@ -33,15 +34,24 @@ struct SignalFeedView: View { /// Live stories when online; cached fallback when the feed is empty & offline. private var displayStories: [StorySummary] { + let base: [StorySummary] if !store.stories.isEmpty { - return store.stories.sorted(by: StorySummary.feedOrder) + base = store.stories.sorted(by: StorySummary.feedOrder) + } else if !ws.connectionState.isLive { + base = cachedStories.map(StorySummary.init(cached:)).sorted(by: StorySummary.feedOrder) + } else { + base = [] } - if !ws.connectionState.isLive { - return cachedStories - .map(StorySummary.init(cached:)) - .sorted(by: StorySummary.feedOrder) + guard let interest = selectedInterest else { + // "All" is news-only: F1 lives solely under its own pill. + return base.filter { !Interest.f1.matches($0) } } - return [] + 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) } } var body: some View { @@ -76,6 +86,16 @@ struct SignalFeedView: View { .onChange(of: store.stories) { _, newValue in cacheStories(newValue) } + .task(id: selectedInterest?.id) { + // A filter active but nothing matched in the loaded set → pull a few + // more pages so sparse interests still populate. + guard selectedInterest != nil else { return } + var pages = 0 + while displayStories.isEmpty && store.hasMore && pages < 8 { + await store.loadMore() + pages += 1 + } + } } // MARK: - Header @@ -127,23 +147,15 @@ struct SignalFeedView: View { } } - // MARK: - Topic pills + // MARK: - Pinned interest pills private var topicPills: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { - ForEach(Topic.filters, id: \.label) { filter in - let selected = store.selectedTopic == filter.slug - Button { - Task { await store.setTopic(filter.slug) } - } label: { - Text(filter.label) - .font(.system(size: 13, weight: .bold)) - .foregroundStyle(selected ? .black : Color(hex: "AAAAAA")) - .padding(.horizontal, 14) - .frame(height: 32) - .background(selected ? Palette.orange : Palette.surface) - .clipShape(Capsule()) + pill("All", selected: selectedInterest == nil) { selectedInterest = nil } + ForEach(Interest.pinned) { interest in + pill(interest.label, selected: selectedInterest?.id == interest.id) { + selectedInterest = (selectedInterest?.id == interest.id) ? nil : interest } } } @@ -152,6 +164,18 @@ struct SignalFeedView: View { .padding(.bottom, 14) } + private func pill(_ label: String, selected: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(label) + .font(.system(size: 13, weight: .bold)) + .foregroundStyle(selected ? .black : Color(hex: "AAAAAA")) + .padding(.horizontal, 14) + .frame(height: 32) + .background(selected ? Palette.orange : Palette.surface) + .clipShape(Capsule()) + } + } + // MARK: - Column headers private var columnHeaders: some View { @@ -228,6 +252,19 @@ struct SignalFeedView: View { Text("No cached stories to show") .font(.system(size: 12)).foregroundStyle(Color(hex: "888888")) refreshButton("Retry") + } else if let interest = selectedInterest { + emptyIcon("line.3.horizontal.decrease.circle") + Text("No \(interest.label) stories yet") + .font(.system(size: 15, weight: .heavy)).foregroundStyle(.white) + if store.isLoadingMore || store.hasMore { + Text("Pulling more to find them…") + .font(.system(size: 12)).foregroundStyle(Color(hex: "888888")) + ProgressView().tint(Palette.orange) + } else { + Text("Nothing in the feed matched") + .font(.system(size: 12)).foregroundStyle(Color(hex: "888888")) + refreshButton("Refresh") + } } else { // Connected, loaded, genuinely nothing yet. emptyIcon("antenna.radiowaves.left.and.right") diff --git a/Jarvis/Views/Home/StoryRowView.swift b/Jarvis/Views/Home/StoryRowView.swift index 564b4b5..f23b3ea 100644 --- a/Jarvis/Views/Home/StoryRowView.swift +++ b/Jarvis/Views/Home/StoryRowView.swift @@ -31,6 +31,16 @@ struct StoryRowView: View { } } + // Summary preview — the gist, without opening the story. + if !story.summary.isEmpty { + Text(story.summary) + .font(.system(size: 13, weight: .regular)) + .foregroundStyle(Color(hex: "8A8A8A")) + .lineLimit(6) + .lineSpacing(2) + .fixedSize(horizontal: false, vertical: true) + } + // Source chips if !sourceNames.isEmpty { SourceChips(names: sourceNames, total: story.sourceCount) diff --git a/Jarvis/Views/Shared/Theme.swift b/Jarvis/Views/Shared/Theme.swift index 364645b..6be0986 100644 --- a/Jarvis/Views/Shared/Theme.swift +++ b/Jarvis/Views/Shared/Theme.swift @@ -171,3 +171,69 @@ extension StorySummary { a.signalScore != b.signalScore ? a.signalScore > b.signalScore : a.id > b.id } } + +// MARK: - Pinned interest filters (client-side) +// +// The backend only classifies topics as finance/tech/politics/africa, so these +// personal filters match keywords in the headline, summary, and source names of +// the loaded stories instead of calling the API. + +struct Interest: Identifiable, Hashable { + let id: String + let label: String + var keywords: [String] = [] + /// If any of these appear, the story is NOT this interest — used to keep the + /// region pills exclusive (East Africa excludes Uganda & South Africa). + var excludeKeywords: [String] = [] + /// 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 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() + if excludeKeywords.contains(where: { hay.contains($0) }) { return false } + return keywords.contains { hay.contains($0) } + } + + // 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", + "entebbe", "jinja", "gulu"] + private static let southAfricaKeywords = ["south africa", "south african", "johannesburg", + "cape town", "pretoria", "durban", "ramaphosa", + "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) + + /// Pinned filter pills, in order. Regions are mutually exclusive. + 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"), + ] +}