Filter pills by backend tags (slug membership), drop keyword matching
Some checks failed
CI / Build · macos-14 · Debug (push) Has been cancelled
CI / Build · macos-15 · Debug (push) Has been cancelled
CI / Build · macos-14 · Release (push) Has been cancelled
CI / Build · macos-15 · Release (push) Has been cancelled

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>
This commit is contained in:
Robin Kutesa
2026-06-19 21:55:57 +03:00
parent 5c0f4afd80
commit 7283fd63ed
5 changed files with 66 additions and 133 deletions

View File

@@ -28,7 +28,7 @@ struct SignalFeedView: View {
@Query private var seenStories: [SeenStory]
@State private var showFeeds = false
@State private var showSettings = false
@State private var selectedInterest: Interest? = nil
@State private var selectedPill: StoryPill = .all
@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).
@@ -40,8 +40,8 @@ struct SignalFeedView: View {
private var readStoryIds: Set<String> { Set(readStories.map(\.id)) }
private var savedStoryIds: Set<String> { Set(savedStories.map(\.id)) }
/// Live stories when online; cached fallback when offline. Interest-filtered,
/// not yet split by read state.
/// Live stories when online; cached fallback when offline. Filtered by the
/// selected pill via backend tags no headline keyword matching.
private var filteredStories: [StorySummary] {
let base: [StorySummary]
if !store.stories.isEmpty {
@@ -51,17 +51,7 @@ struct SignalFeedView: View {
} else {
base = []
}
// Sports (incl. F1) live only under their own pills the backend mis-tags
// them into finance/tech/politics, so exclude them everywhere else.
let isSport: (StorySummary) -> Bool = { Interest.isFormula1($0) || Interest.isSports($0) }
if let interest = selectedInterest {
if interest.id == "f1" || interest.id == "sport" {
return base.filter(interest.matches)
}
return base.filter { interest.matches($0) && !isSport($0) }
}
// "All" is news-only: no sports.
return base.filter { !isSport($0) }
return base.filter { $0.matches(selectedPill) }
}
/// The main feed: unread stories, with already-seen ones sunk below fresh ones.
@@ -130,10 +120,10 @@ struct SignalFeedView: View {
.onChange(of: store.stories) { _, newValue in
cacheStories(newValue)
}
.task(id: selectedInterest?.id) {
.task(id: selectedPill) {
// 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 }
// more pages so sparse pills still populate.
guard selectedPill != .all else { return }
var pages = 0
while filteredStories.isEmpty && store.hasMore && pages < 8 {
await store.loadMore()
@@ -191,16 +181,13 @@ struct SignalFeedView: View {
}
}
// MARK: - Pinned interest pills
// MARK: - Pills
private var topicPills: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
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
}
ForEach(StoryPill.allCases) { p in
pill(p.label, selected: selectedPill == p) { selectedPill = p }
}
}
.padding(.horizontal, 16)
@@ -382,9 +369,9 @@ struct SignalFeedView: View {
Text("No cached stories to show")
.font(.system(size: 12)).foregroundStyle(Color(hex: "888888"))
refreshButton("Retry")
} else if let interest = selectedInterest {
} else if selectedPill != .all {
emptyIcon("line.3.horizontal.decrease.circle")
Text("No \(interest.label) stories yet")
Text("No \(selectedPill.label) stories yet")
.font(.system(size: 15, weight: .heavy)).foregroundStyle(.white)
if store.isLoadingMore || store.hasMore {
Text("Pulling more to find them…")
@@ -496,6 +483,7 @@ extension StorySummary {
headline: cached.headline,
summary: cached.summary,
topic: cached.topic,
tags: nil,
signalScore: cached.signalScore,
scoreBreakdown: ScoreBreakdown(sourceAuthority: 0, freshness: 0, localRelevance: 0,
crossSourceConfirmation: 0, topicImportance: 0),

View File

@@ -117,7 +117,7 @@ struct StoryRowView: View {
StoryRowView(
story: StorySummary(
id: "\(score)", headline: "Bank of Uganda cuts interest rates by 50 basis points",
summary: "", topic: "finance", signalScore: score, scoreBreakdown: breakdown,
summary: "", topic: "finance", tags: ["finance"], signalScore: score, scoreBreakdown: breakdown,
sourceCount: 7, sources: sources, consensus: nil, conflict: nil,
updatedAt: Date().addingTimeInterval(-3600), createdAt: Date()),
isCached: score == 64