Filter pills by backend tags (slug membership), drop keyword matching
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:
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user