From 0ccd5554d27464e46b25a1469106901f016364ca Mon Sep 17 00:00:00 2001 From: jay Date: Sun, 31 May 2026 13:22:41 +0000 Subject: [PATCH] Persist replacements across refresh (device-local, no account) A reader who swaps a story away should keep that swap after a refresh; before, the server re-served the original brief. - localStorage now persists seen / dismissed / history (loadJSON/saveJSON). - /api/brief accepts an exclude list; dismissed (replaced-away) ids are dropped and the highlights refill around them, so swaps stick and stay full. - Replace records the swap to dismissed+seen and persists; the seen-set (persisted) keeps Replace from recycling across refreshes too. - History panel survives refresh and gains 'Clear what I've seen (start fresh)' so it never feels suffocating. Saved history/favorites still come with sign-in. Tests: brief exclude + refill (90 total). Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/lib/prefs.js | 18 ++++++++++ frontend/src/routes/+page.svelte | 57 +++++++++++++++++++++++++++----- goodnews/api.py | 34 ++++++++++--------- tests/test_brief_refill.py | 8 +++++ 4 files changed, 94 insertions(+), 23 deletions(-) diff --git a/frontend/src/lib/prefs.js b/frontend/src/lib/prefs.js index 445b266..6f6a039 100644 --- a/frontend/src/lib/prefs.js +++ b/frontend/src/lib/prefs.js @@ -53,6 +53,24 @@ export function merge(userPrefs, moodFilter = {}) { return m; } +// Generic device-local JSON storage (used for session memory: seen / dismissed +// / history), so an account-less reader's choices survive a refresh. +export function loadJSON(key, fallback) { + try { + const v = JSON.parse(localStorage.getItem(key)); + return v == null ? fallback : v; + } catch { + return fallback; + } +} +export function saveJSON(key, value) { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch { + /* private mode / quota — non-fatal */ + } +} + // "" or "prefs=" for a query string. export function param(prefs) { const empty = diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 7c50a6c..b38ade2 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -16,18 +16,45 @@ let loading = $state(true); let error = $state(''); - // Session memory (this tab only — cleared on close, no account): - // every article shown is remembered so Replace never recycles something - // already seen, and so a replaced-away story stays recoverable in History. - const seenIds = new Set(); + // Device-local memory (no account): persisted in localStorage so it survives + // a refresh. `seen` stops Replace recycling; `dismissed` (replaced-away) is + // excluded from the brief so swaps stick; `history` keeps everything seen + // recoverable. A reader can wipe it all from the History panel. + const SEEN_KEY = 'goodnews:seen'; + const DISMISSED_KEY = 'goodnews:dismissed'; + const HISTORY_KEY = 'goodnews:history'; + const HISTORY_CAP = 200; + + let seenIds = new Set(); + let dismissed = new Set(); let history = $state([]); + + function persistSession() { + P.saveJSON(SEEN_KEY, [...seenIds]); + P.saveJSON(DISMISSED_KEY, [...dismissed]); + P.saveJSON(HISTORY_KEY, history.slice(0, HISTORY_CAP)); + } function remember(items) { + let changed = false; for (const a of items || []) { if (a && !seenIds.has(a.id)) { seenIds.add(a.id); history.unshift(a); + changed = true; } } + if (changed) { + if (history.length > HISTORY_CAP) history = history.slice(0, HISTORY_CAP); + persistSession(); + } + } + function clearSession() { + seenIds = new Set(); + dismissed = new Set(); + history = []; + persistSession(); + showHistory = false; + select(selected); } let filtersOn = $derived(P.active(userPrefs)); @@ -50,7 +77,8 @@ // category only when chosen. if (key === 'today') { const q = P.param(userPrefs); - brief = await getJSON(`/api/brief?limit=7${q ? '&' + q : ''}`); + const ex = Array.from(dismissed).join(','); + brief = await getJSON(`/api/brief?limit=7${q ? '&' + q : ''}${ex ? '&exclude=' + ex : ''}`); remember(brief.items); } else { const mood = moods.find((m) => m.key === key); @@ -84,7 +112,7 @@ const list = selected === 'today' ? brief?.items : feed; if (!list) return; const isHero = selected === 'today' && list[0]?.id === article.id; - // Exclude everything seen this session, so Replace never cycles back. + // Exclude everything seen (persisted), so Replace never cycles back. const exclude = Array.from(seenIds).join(','); const q = mergedParam(); const url = `/api/replacement?exclude=${exclude}&avoid_paywall=true${isHero ? '&gentle=true' : ''}${q ? '&' + q : ''}`; @@ -99,7 +127,12 @@ flash("That's everything fresh for now — nothing new to swap in."); return; } + // Remember the swap so it sticks across refreshes (the dismissed story is + // excluded from future briefs; it stays recoverable in History). + dismissed.add(article.id); + seenIds.add(article.id); remember([repl]); + persistSession(); if (selected === 'today') { const i = brief.items.findIndex((a) => a.id === article.id); if (i >= 0) { @@ -117,6 +150,9 @@ onMount(async () => { userPrefs = P.load(); + seenIds = new Set(P.loadJSON(SEEN_KEY, [])); + dismissed = new Set(P.loadJSON(DISMISSED_KEY, [])); + history = P.loadJSON(HISTORY_KEY, []); try { moods = await getJSON('/api/moods'); await select('today'); @@ -145,10 +181,10 @@ {#if showHistory}
-

This session

+

What you've seen

-

Everything you've seen this visit, including stories you swapped away. Kept on this device for this tab only — it clears when you close it. (Saved history & favorites come with sign-in, later.)

+

Everything you've seen here, including stories you swapped away — so a swap sticks and stays recoverable. Kept on this device only (no account, nothing sent). (Saved history & favorites come with sign-in, later.)

{#if history.length}
    {#each history as a (a.id)} @@ -161,6 +197,9 @@ {:else}

    Nothing yet — your seen stories will appear here.

    {/if} + {#if history.length || dismissed.size} + + {/if}
{/if} @@ -243,6 +282,8 @@ .hist a:hover { color: var(--sage-deep); } .hsrc { margin-left: auto; color: var(--muted); font-size: 0.78rem; white-space: nowrap; } .empty { margin: 0; color: var(--muted); font-style: italic; font-size: 0.85rem; } + .reset { background: none; border: none; color: var(--muted); font-size: 0.82rem; text-decoration: underline; margin-top: 12px; } + .reset:hover { color: var(--sage-deep); } .notice { text-align: center; color: var(--sage-deep); background: var(--sage-soft); diff --git a/goodnews/api.py b/goodnews/api.py index f35a0cf..104c618 100644 --- a/goodnews/api.py +++ b/goodnews/api.py @@ -313,28 +313,32 @@ def create_app() -> FastAPI: date: str | None = Query(None), limit: int = Query(10, ge=1, le=50), prefs: str | None = Query(None), + exclude: str = Query("", description="comma-separated article ids the reader has dismissed"), ) -> BriefResponse: fp = prefs_from_json(prefs) now = datetime.now(timezone.utc) + excl = {int(x) for x in exclude.split(",") if x.strip().lstrip("-").isdigit()} with get_conn() as conn: data = queries.brief(conn, brief_date=date, limit=limit) - items = data["items"] + # Drop dismissed (replaced-away) items and anything the reader's + # boundaries hide; avoid-terms take precedence over curation. + items = [a for a in data["items"] if a["id"] not in excl] if not fp.is_empty(): - # Apply personal boundaries (avoid-terms take precedence over curation). items = filter_articles(items, fp, now) - # Keep the highlights full: if a boundary hid a story, top up with - # other readable, boundary-respecting good news rather than show fewer. - if len(items) < limit: - have = {a["id"] for a in items} - pool = queries.feed( - conn, accepted_only=True, limit=limit * 5 + 20, offset=0, **_prefs_sql_kw(fp, now) - ) - for a in filter_articles(pool, fp, now): - if len(items) >= limit: - break - if a["id"] not in have: - items.append(a) - have.add(a["id"]) + # Keep the highlights full: if a boundary or a dismissal removed a + # story, top up with other readable, boundary-respecting good news + # rather than show fewer. + if len(items) < limit: + have = {a["id"] for a in items} | excl + pool = queries.feed( + conn, accepted_only=True, limit=limit * 5 + 40, offset=0, **_prefs_sql_kw(fp, now) + ) + for a in filter_articles(pool, fp, now): + if len(items) >= limit: + break + if a["id"] not in have: + items.append(a) + have.add(a["id"]) # Lead with a gentle, readable story (charged or paywalled stories stay # in the set, just not as the first thing seen). items = _pick_lead(items) diff --git a/tests/test_brief_refill.py b/tests/test_brief_refill.py index 124ce03..30f66f3 100644 --- a/tests/test_brief_refill.py +++ b/tests/test_brief_refill.py @@ -48,3 +48,11 @@ def test_brief_refill_respects_avoid_terms(client): client_resp = client.get("/api/brief", params={"limit": 3, "prefs": json.dumps({"avoid_terms": ["t1"]})}).json() assert len(client_resp["items"]) == 3 assert all(i["id"] != 1 for i in client_resp["items"]) + + +def test_brief_excludes_dismissed_and_refills(client): + # Dismiss the first brief item; the highlights should stay full and not show it. + out = client.get("/api/brief", params={"limit": 3, "exclude": "1"}).json() + ids = [i["id"] for i in out["items"]] + assert len(out["items"]) == 3 + assert 1 not in ids