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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user