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) <noreply@anthropic.com>
This commit is contained in:
@@ -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=<encoded json>" for a query string.
|
||||
export function param(prefs) {
|
||||
const empty =
|
||||
|
||||
@@ -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}
|
||||
<section class="panel rise">
|
||||
<div class="phead">
|
||||
<h2>This session</h2>
|
||||
<h2>What you've seen</h2>
|
||||
<button class="close" onclick={() => (showHistory = false)}>done</button>
|
||||
</div>
|
||||
<p class="reassure">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.)</p>
|
||||
<p class="reassure">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.)</p>
|
||||
{#if history.length}
|
||||
<ul class="hist">
|
||||
{#each history as a (a.id)}
|
||||
@@ -161,6 +197,9 @@
|
||||
{:else}
|
||||
<p class="empty">Nothing yet — your seen stories will appear here.</p>
|
||||
{/if}
|
||||
{#if history.length || dismissed.size}
|
||||
<button class="reset" onclick={clearSession}>Clear what I've seen (start fresh)</button>
|
||||
{/if}
|
||||
</section>
|
||||
{/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);
|
||||
|
||||
+19
-15
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user