feat(mana/web/news): "interested" keeps article visible + saved badge

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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-09 19:48:53 +02:00
parent 55bf493f44
commit acfbcfe689
5 changed files with 106 additions and 19 deletions

View file

@ -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();

View file

@ -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<string>;
/**
* 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<string>;
/** Set of curatedArticleIds the user marked as interested. */
interestedIds: ReadonlySet<string>;
}
/** 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<string>;
interestedIds: Set<string>;
} {
const dismissedIds = new Set<string>();
const interestedIds = new Set<string>();
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<string> {
const set = new Set<string>();
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<string> {
* 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;

View file

@ -29,6 +29,7 @@ export {
rankFeed,
scoreArticle,
buildReactedIds,
buildReactionSets,
applyReaction,
TOPIC_WEIGHT_DEFAULT,
} from './feed-engine';

View file

@ -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 }) => {

View file

@ -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}
<div class="card-grid">
{#each ranked as { article } (article.id)}
{@const isSaved = interestedIds.has(article.id)}
<article class="card">
{#if article.imageUrl}
<button
@ -324,7 +327,7 @@
<img src={article.imageUrl} alt="" loading="lazy" />
</button>
{/if}
<div class="card-body">
<div class="card-body" class:is-saved={isSaved}>
<div class="card-meta">
<span class="source">{article.siteName}</span>
<span class="dot">·</span>
@ -333,6 +336,9 @@
<span class="dot">·</span>
<span>{article.readingTimeMinutes} min</span>
{/if}
{#if isSaved}
<span class="saved-badge" title="In deiner Leseliste">❤️ gespeichert</span>
{/if}
</div>
<button type="button" class="card-title-btn" onclick={() => openReader(article)}>
{article.title}
@ -344,10 +350,14 @@
<button
type="button"
class="reaction-btn interested"
class:active={isSaved}
onclick={() => react(article, 'interested')}
title="Speichern + mehr davon"
title={isSaved
? 'Schon gespeichert — nochmal klicken bestätigt nur'
: 'In Leseliste speichern + mehr davon zeigen'}
disabled={isSaved}
>
❤️ Interessiert
❤️ {isSaved ? 'Gespeichert' : 'Interessiert'}
</button>
<button
type="button"
@ -716,4 +726,31 @@
.reaction-btn.interested:hover {
border-color: hsl(var(--color-primary));
}
.reaction-btn.interested.active {
background: hsl(var(--color-primary) / 0.18);
border-color: hsl(var(--color-primary) / 0.5);
color: hsl(var(--color-primary));
cursor: default;
opacity: 0.85;
}
.reaction-btn.interested.active:hover {
filter: none;
}
.saved-badge {
display: inline-flex;
align-items: center;
gap: 0.2rem;
padding: 0 0.4rem;
border-radius: 999px;
background: hsl(var(--color-primary) / 0.15);
color: hsl(var(--color-primary));
font-size: 0.6875rem;
font-weight: 600;
margin-left: auto;
}
.card-body.is-saved {
/* Subtle visual cue that this article is in the reading list,
* but still readable as a feed card. */
background: hsl(var(--color-primary) / 0.04);
}
</style>