From acfbcfe689e6015d87d0b711cf91699732d84881 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 9 Apr 2026 19:48:53 +0200 Subject: [PATCH] feat(mana/web/news): "interested" keeps article visible + saved badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switches the feed engine to a softer reaction model: ❤️ Interessiert no longer hides the article from the feed, only adds it to the reading list and bumps the topic + source weights. The article keeps its slot in the ranked feed and gets a "❤️ gespeichert" badge in the card meta + a tinted card background so the user can see at a glance "yep, this is already in my reading list". The previous behavior — interested = save + remove from feed — was modeled on a Pocket-style "save and move on" pattern, but turns out to be confusing in a discovery-feed context: tapping a positive signal made the article disappear, which feels like punishment. Variante B (this commit) makes the destructive vs non-destructive split explicit: 👎 Nicht für mich and 🚫 Quelle ausblenden are the ones that hide articles, ❤️ is purely additive. ═══ Engine ═══ `scoreArticle()` now reads `dismissedIds` (the set of articles with not_interested or hidden reactions) for the hard-hide filter instead of the old `reactedIds` (which lumped all reaction kinds together). `interestedIds` is passed alongside so views can render the badge without re-deriving from the raw reactions array. `buildReactionSets()` is the new helper that splits the reactions into the two sets in one pass. `buildReactedIds()` is kept as a deprecated alias that returns just the dismissed set — same effect on the feed filter for any not-yet-migrated caller, and any old "interested = hidden" behavior is now lost (which is the goal). ═══ UI ═══ The feed page card body gets a `.is-saved` modifier that tints the background, the card meta row gets a saved-badge pill, and the interested button shows "Gespeichert" + a filled-in active state + disabled cursor when the article is already in the reading list. A second click on an already-saved article is a no-op now. The workbench ListView and the dashboard NewsUnreadWidget got the same engine update so the three surfaces stay in sync — the badge UI itself is only on the main feed for now since the workbench card is too narrow to fit it cleanly. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../web/src/lib/modules/news/ListView.svelte | 8 ++- .../web/src/lib/modules/news/feed-engine.ts | 63 ++++++++++++++++--- .../apps/web/src/lib/modules/news/index.ts | 1 + .../news/widgets/NewsUnreadWidget.svelte | 4 +- .../web/src/routes/(app)/news/+page.svelte | 49 +++++++++++++-- 5 files changed, 106 insertions(+), 19 deletions(-) diff --git a/apps/mana/apps/web/src/lib/modules/news/ListView.svelte b/apps/mana/apps/web/src/lib/modules/news/ListView.svelte index 23d995cd9..79a14289d 100644 --- a/apps/mana/apps/web/src/lib/modules/news/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/news/ListView.svelte @@ -24,7 +24,7 @@ useReactions, formatRelativeTime, } from '$lib/modules/news/queries'; - import { rankFeed, buildReactedIds } from '$lib/modules/news/feed-engine'; + import { rankFeed, buildReactionSets } from '$lib/modules/news/feed-engine'; import { reactionsStore } from '$lib/modules/news/stores/reactions.svelte'; import { articlesStore } from '$lib/modules/news/stores/articles.svelte'; import { feedCacheStore } from '$lib/modules/news/stores/feed-cache.svelte'; @@ -45,8 +45,10 @@ const pool = $derived(pool$.value); const reactions = $derived(reactions$.value); - const reactedIds = $derived(buildReactedIds(reactions)); - const ranked = $derived(prefs.onboardingCompleted ? rankFeed(pool, { prefs, reactedIds }) : []); + const { dismissedIds, interestedIds } = $derived(buildReactionSets(reactions)); + const ranked = $derived( + prefs.onboardingCompleted ? rankFeed(pool, { prefs, dismissedIds, interestedIds }) : [] + ); onMount(() => { feedCacheStore.start(); diff --git a/apps/mana/apps/web/src/lib/modules/news/feed-engine.ts b/apps/mana/apps/web/src/lib/modules/news/feed-engine.ts index 8f2f68245..c24bdf46f 100644 --- a/apps/mana/apps/web/src/lib/modules/news/feed-engine.ts +++ b/apps/mana/apps/web/src/lib/modules/news/feed-engine.ts @@ -42,15 +42,59 @@ function recencyScore(publishedAt: string | null): number { export interface ScoreContext { prefs: Preferences; - /** Set of curatedArticleIds that already have any reaction. */ - reactedIds: ReadonlySet; + /** + * Set of curatedArticleIds the user has actively dismissed + * (`not_interested`, `hidden`, or via `source_blocked`). Used as a + * hard hide-from-feed filter. + * + * `interested` reactions are NOT in this set on purpose — those + * articles stay visible in the feed (with a saved-badge) so the + * user can keep reading and clicking around without articles + * disappearing the moment they tap "❤️". The reading list remains + * the source of truth for "what did I save". + */ + dismissedIds: ReadonlySet; + /** Set of curatedArticleIds the user marked as interested. */ + interestedIds: ReadonlySet; } -/** Build the lookup set once and reuse across all scoreArticle calls. */ +/** + * Split the user's reactions into two sets: dismissed (hard-hide + * from feed) and interested (keep visible, badge in UI). Built once + * per render and reused across all scoreArticle calls. + * + * `source_blocked` reactions are NOT added to dismissedIds even + * though they hide articles — the source-level filter in + * `scoreArticle` handles those via `prefs.blockedSources` instead, + * so adding them here would be a no-op duplicate. + */ +export function buildReactionSets(reactions: readonly Reaction[]): { + dismissedIds: Set; + interestedIds: Set; +} { + const dismissedIds = new Set(); + const interestedIds = new Set(); + for (const r of reactions) { + if (r.reaction === 'interested') { + interestedIds.add(r.articleId); + } else if (r.reaction === 'not_interested' || r.reaction === 'hidden') { + dismissedIds.add(r.articleId); + } + } + return { dismissedIds, interestedIds }; +} + +/** + * @deprecated Kept for backwards compat with the dashboard widget. + * New call sites should use `buildReactionSets` and the + * dismissedIds/interestedIds shape instead. This helper now returns + * only the dismissed ids — same effect on the feed filter, but it + * means widgets that haven't migrated still hide + * `not_interested`/`hidden` articles correctly while leaving + * `interested` articles visible (matching the new feed behavior). + */ export function buildReactedIds(reactions: readonly Reaction[]): Set { - const set = new Set(); - for (const r of reactions) set.add(r.articleId); - return set; + return buildReactionSets(reactions).dismissedIds; } /** @@ -58,7 +102,7 @@ export function buildReactedIds(reactions: readonly Reaction[]): Set { * should be hidden entirely. Callers sort by descending score. */ export function scoreArticle(article: LocalCachedArticle, ctx: ScoreContext): number | null { - const { prefs, reactedIds } = ctx; + const { prefs, dismissedIds } = ctx; if (prefs.selectedTopics.length > 0 && !prefs.selectedTopics.includes(article.topic as never)) { return null; @@ -70,7 +114,10 @@ export function scoreArticle(article: LocalCachedArticle, ctx: ScoreContext): nu ) { return null; } - if (reactedIds.has(article.id)) return null; + // Only ACTIVELY DISMISSED articles are filtered out. `interested` + // reactions stay visible in the feed (the user's read state is + // communicated via the badge in the UI, not by hiding the card). + if (dismissedIds.has(article.id)) return null; const topicW = prefs.topicWeights[article.topic] ?? TOPIC_WEIGHT_DEFAULT; const sourceW = prefs.sourceWeights[article.sourceSlug] ?? TOPIC_WEIGHT_DEFAULT; diff --git a/apps/mana/apps/web/src/lib/modules/news/index.ts b/apps/mana/apps/web/src/lib/modules/news/index.ts index 5a14f29b0..5bcb12abf 100644 --- a/apps/mana/apps/web/src/lib/modules/news/index.ts +++ b/apps/mana/apps/web/src/lib/modules/news/index.ts @@ -29,6 +29,7 @@ export { rankFeed, scoreArticle, buildReactedIds, + buildReactionSets, applyReaction, TOPIC_WEIGHT_DEFAULT, } from './feed-engine'; diff --git a/apps/mana/apps/web/src/lib/modules/news/widgets/NewsUnreadWidget.svelte b/apps/mana/apps/web/src/lib/modules/news/widgets/NewsUnreadWidget.svelte index 799074725..77e8d9afe 100644 --- a/apps/mana/apps/web/src/lib/modules/news/widgets/NewsUnreadWidget.svelte +++ b/apps/mana/apps/web/src/lib/modules/news/widgets/NewsUnreadWidget.svelte @@ -21,7 +21,7 @@ DEFAULT_PREFERENCES, } from '$lib/modules/news/collections'; import { decryptRecords } from '$lib/data/crypto'; - import { rankFeed, buildReactedIds } from '$lib/modules/news/feed-engine'; + import { rankFeed, buildReactionSets } from '$lib/modules/news/feed-engine'; import { toPreferences, toReaction, formatRelativeTime } from '$lib/modules/news/queries'; import { PREFERENCES_ID, type LocalCachedArticle } from '$lib/modules/news/types'; @@ -49,7 +49,7 @@ return { prefs, - ranked: rankFeed(pool, { prefs, reactedIds: buildReactedIds(reactions) }), + ranked: rankFeed(pool, { prefs, ...buildReactionSets(reactions) }), }; }).subscribe({ next: ({ prefs, ranked }) => { diff --git a/apps/mana/apps/web/src/routes/(app)/news/+page.svelte b/apps/mana/apps/web/src/routes/(app)/news/+page.svelte index a2cedd06a..f5e4554e9 100644 --- a/apps/mana/apps/web/src/routes/(app)/news/+page.svelte +++ b/apps/mana/apps/web/src/routes/(app)/news/+page.svelte @@ -13,7 +13,7 @@ useReactions, formatRelativeTime, } from '$lib/modules/news/queries'; - import { rankFeed, buildReactedIds } from '$lib/modules/news/feed-engine'; + import { rankFeed, buildReactionSets } from '$lib/modules/news/feed-engine'; import { preferencesStore } from '$lib/modules/news/stores/preferences.svelte'; import { reactionsStore } from '$lib/modules/news/stores/reactions.svelte'; import { articlesStore } from '$lib/modules/news/stores/articles.svelte'; @@ -102,12 +102,14 @@ } // ─── Feed branch ────────────────────────────────────────── - const reactedIds = $derived(buildReactedIds(reactions)); + const { dismissedIds, interestedIds } = $derived(buildReactionSets(reactions)); // Treat the local "just finished" override as fully onboarded so the // feed renders immediately after the user clicks Fertig, before the // liveQuery has had a chance to refresh prefs. const isOnboarded = $derived(prefs.onboardingCompleted || onboardingJustFinished); - const ranked = $derived(isOnboarded ? rankFeed(pool, { prefs, reactedIds }) : []); + const ranked = $derived( + isOnboarded ? rankFeed(pool, { prefs, dismissedIds, interestedIds }) : [] + ); async function react( article: LocalCachedArticle, @@ -313,6 +315,7 @@ {:else}
{#each ranked as { article } (article.id)} + {@const isSaved = interestedIds.has(article.id)}
{#if article.imageUrl} {/if} -
+
{article.siteName} · @@ -333,6 +336,9 @@ · {article.readingTimeMinutes} min {/if} + {#if isSaved} + ❤️ gespeichert + {/if}