Rework history: opened + replaced only, with per-item removal

History was logging every article merely displayed, which made it noise. Split
the two concepts cleanly:
- "displayed" (seenIds) still tracks everything shown, but only to stop Replace
  recycling stories — it no longer feeds history.
- "history" now records only deliberate events: articles the user OPENED (card
  click) or ones they REPLACED away (recoverable accidental swaps).

Also: per-item removal (× in the History panel; DELETE /api/history/{id}), and
when signed in the panel shows the account (cross-device) history. First-sign-in
import now folds the meaningful history (not everything shown). Copy updated.
115 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-03 13:27:39 +00:00
parent 409bb11444
commit 1aa250ca67
4 changed files with 88 additions and 40 deletions
@@ -1,7 +1,7 @@
<script>
import { auth, savedIds, toggleSave } from '$lib/auth.svelte.js';
let { article, onaction, onreplace, ontag, onimageerror, hero = false } = $props();
let { article, onaction, onreplace, ontag, onimageerror, onview, hero = false } = $props();
let isSaved = $derived(savedIds.has(article.id));
const humanize = (s) => (s || '').replace(/-/g, ' ');
@@ -50,7 +50,7 @@
data-topic={article.topic ?? ''}
>
{#if showImage}
<a class="media" href={safeHref} target="_blank" rel="noopener">
<a class="media" href={safeHref} target="_blank" rel="noopener" onclick={() => onview?.(article)}>
<img src={article.image_url} alt="" loading="lazy" referrerpolicy="no-referrer"
onerror={() => { failed = true; onimageerror?.(); }} />
</a>
@@ -71,7 +71,7 @@
</div>
<div class="src">{article.source}</div>
<h3><a href={safeHref} target="_blank" rel="noopener">{article.title}</a></h3>
<h3><a href={safeHref} target="_blank" rel="noopener" onclick={() => onview?.(article)}>{article.title}</a></h3>
{#if hero && article.description}
<p class="desc">{article.description}</p>
+71 -37
View File
@@ -1,6 +1,6 @@
<script>
import { onMount } from 'svelte';
import { getJSON, postJSON } from '$lib/api.js';
import { getJSON, postJSON, delJSON } from '$lib/api.js';
import * as P from '$lib/prefs.js';
import Header from '$lib/components/Header.svelte';
import BottomNav from '$lib/components/BottomNav.svelte';
@@ -32,6 +32,11 @@
await authLogout();
showYou = false;
}
function openHistory() {
showYou = false;
showHistory = true;
loadServerHistory();
}
// On first sign-in (per account, per device), fold this device's anonymous
// history + saved into the account so nothing's lost.
@@ -40,9 +45,10 @@
if (!u || typeof window === 'undefined') return;
const key = 'goodnews:imported:' + u.id;
if (localStorage.getItem(key)) return;
const seen = [...new Set([...seenIds, ...history.map((a) => a.id)])];
// Fold in this device's MEANINGFUL history (opened/replaced), not everything shown.
const seen = history.map((a) => a.id);
postJSON('/api/import', { seen, saved: [] })
.then(() => localStorage.setItem(key, '1'))
.then(() => { localStorage.setItem(key, '1'); loadServerHistory(); })
.catch(() => {});
});
let loading = $state(true);
@@ -55,32 +61,48 @@
const BRIEF_VIEW_KEY = 'goodnews:brief_view';
const HISTORY_CAP = 200;
let seenIds = new Set();
let seenIds = new Set(); // articles DISPLAYED — so Replace doesn't recycle them
let dismissed = $state(new Set());
let history = $state([]);
let history = $state([]); // articles OPENED or REPLACED-away — the meaningful history
let serverHistory = $state([]); // account history (cross-device) when signed in
function persistSession() {
P.saveJSON(SEEN_KEY, [...seenIds]);
P.saveJSON(DISMISSED_KEY, [...dismissed]);
P.saveJSON(HISTORY_KEY, history.slice(0, HISTORY_CAP));
}
function remember(items) {
// Mark articles as shown (for Replace exclusion only — NOT history).
function markDisplayed(items) {
let changed = false;
const freshIds = [];
for (const a of items || []) {
if (a && !seenIds.has(a.id)) {
seenIds.add(a.id);
history.unshift(a);
freshIds.push(a.id);
changed = true;
}
if (a && !seenIds.has(a.id)) { seenIds.add(a.id); changed = true; }
}
if (changed) {
if (history.length > HISTORY_CAP) history = history.slice(0, HISTORY_CAP);
persistSession();
// Mirror newly-seen items into the account history (cross-device), best-effort.
if (auth.user && freshIds.length) postJSON('/api/history', { ids: freshIds }).catch(() => {});
if (changed) P.saveJSON(SEEN_KEY, [...seenIds]);
}
// Record a deliberate event: an article the user OPENED, or one they REPLACED
// away (kept so an accidental replace is recoverable).
function recordHistory(article) {
if (!article) return;
if (!history.some((h) => h.id === article.id)) {
history = [article, ...history].slice(0, HISTORY_CAP);
P.saveJSON(HISTORY_KEY, history);
}
if (auth.user) {
if (!serverHistory.some((h) => h.id === article.id)) serverHistory = [article, ...serverHistory];
postJSON('/api/history', { ids: [article.id] }).catch(() => {});
}
}
function removeFromHistory(id) {
history = history.filter((h) => h.id !== id);
serverHistory = serverHistory.filter((h) => h.id !== id);
P.saveJSON(HISTORY_KEY, history);
if (auth.user) delJSON(`/api/history/${id}`).catch(() => {});
}
// The list shown in the History panel: account history when signed in, else device.
let historyItems = $derived(auth.user ? serverHistory : history);
async function loadServerHistory() {
if (!auth.user) return;
try { serverHistory = (await getJSON('/api/history')).items; } catch { /* leave as-is */ }
}
function clearSession() {
seenIds = new Set();
@@ -167,7 +189,7 @@
P.saveJSON(BRIEF_VIEW_KEY, { generated_at: fetched.generated_at, items: fetched.items });
}
heroIdx = 0; // fresh brief — start the hero at the lead again
remember(brief.items);
markDisplayed(brief.items);
}
async function select(key, fresh = false) {
@@ -179,18 +201,18 @@
await loadToday(fresh);
} else if (key === 'saved') {
feed = (await getJSON('/api/saved')).items;
remember(feed);
markDisplayed(feed);
} else if (key.startsWith('tag:')) {
const tag = key.slice(4);
const q = P.param(userPrefs);
const ex = Array.from(dismissed).join(',');
feed = (await getJSON(`/api/feed?limit=24&tag=${encodeURIComponent(tag)}${q ? '&' + q : ''}${ex ? '&exclude=' + ex : ''}`)).items;
remember(feed);
markDisplayed(feed);
} else {
const q = P.param(P.merge(userPrefs, viewFilter(key)));
const ex = Array.from(dismissed).join(',');
feed = (await getJSON(`/api/feed?limit=24${q ? '&' + q : ''}${ex ? '&exclude=' + ex : ''}`)).items;
remember(feed);
markDisplayed(feed);
}
} catch (e) {
error = 'Something went quiet — could not reach the feed.';
@@ -234,7 +256,8 @@
}
dismissed.add(article.id);
seenIds.add(article.id);
remember([repl]);
markDisplayed([repl]);
recordHistory(article); // keep the swapped-away story so an accidental replace is recoverable
persistSession();
if (selected === 'today') {
const i = brief.items.findIndex((a) => a.id === article.id);
@@ -282,7 +305,7 @@
<Header
onBoundaries={() => (showBoundaries = !showBoundaries)}
onHistory={() => (showHistory = !showHistory)}
onHistory={() => (showHistory ? (showHistory = false) : openHistory())}
onaccount={openAccount}
user={auth.user}
{filtersOn}
@@ -302,21 +325,30 @@
{#if showHistory}
<section class="panel rise">
<div class="phead">
<h2>What you've seen</h2>
<h2>History</h2>
<button class="close" onclick={() => (showHistory = false)}>done</button>
</div>
<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).</p>
{#if history.length}
<p class="reassure">
Stories you've opened, plus any you swapped away — so an accidental Replace stays
recoverable. {auth.user ? 'Synced to your account, across devices.' : 'Kept on this device only.'}
Remove anything you don't want to keep.
</p>
{#if historyItems.length}
<ul class="hist">
{#each history as a (a.id)}
<li><a href={a.url} target="_blank" rel="noopener">{a.title}</a><span class="hsrc">{a.source}</span></li>
{#each historyItems as a (a.id)}
<li>
<a href={a.url} target="_blank" rel="noopener" onclick={() => recordHistory(a)}>{a.title}</a>
<span class="hsrc">{a.source}</span>
<button class="hx" title="Remove from history" aria-label="Remove from history"
onclick={() => removeFromHistory(a.id)}>×</button>
</li>
{/each}
</ul>
{:else}
<p class="empty">Nothing yet — your seen stories will appear here.</p>
<p class="empty">Nothing yet — stories you open or swap away will appear here.</p>
{/if}
{#if history.length || dismissed.size}
<button class="reset" onclick={clearSession}>Clear what I've seen (start fresh)</button>
{#if historyItems.length || dismissed.size}
<button class="reset" onclick={clearSession}>Clear my history (start fresh)</button>
{/if}
</section>
{/if}
@@ -333,8 +365,8 @@
<button class="yourow" onclick={() => { showYou = false; showBoundaries = true; }}>
<span>Your boundaries</span>{#if filtersOn}<span class="dot">on</span>{/if}
</button>
<button class="yourow" onclick={() => { showYou = false; showHistory = true; }}>
<span>What you've seen</span>{#if history.length}<span class="dot">{history.length}</span>{/if}
<button class="yourow" onclick={openHistory}>
<span>History</span>{#if historyItems.length}<span class="dot">{historyItems.length}</span>{/if}
</button>
{#if auth.user}
<button class="yourow" onclick={signOut}><span>Sign out</span></button>
@@ -362,11 +394,11 @@
{#if selected === 'today'}
{#if brief?.items?.length}
<section class="rise">
<ArticleCard article={heroArticle} hero onaction={applyAction} onreplace={replaceArticle} ontag={(t) => select('tag:' + t)} onimageerror={heroImageFailed} />
<ArticleCard article={heroArticle} hero onaction={applyAction} onreplace={replaceArticle} ontag={(t) => select('tag:' + t)} onview={recordHistory} onimageerror={heroImageFailed} />
{#if restArticles.length}
<div class="grid rest">
{#each restArticles as a (a.id)}
<ArticleCard article={a} onaction={applyAction} onreplace={replaceArticle} ontag={(t) => select('tag:' + t)} />
<ArticleCard article={a} onaction={applyAction} onreplace={replaceArticle} ontag={(t) => select('tag:' + t)} onview={recordHistory} />
{/each}
</div>
{/if}
@@ -378,7 +410,7 @@
{:else if feed.length}
<div class="grid rise">
{#each feed as a (a.id)}
<ArticleCard article={a} onaction={applyAction} onreplace={replaceArticle} ontag={(t) => select('tag:' + t)} />
<ArticleCard article={a} onaction={applyAction} onreplace={replaceArticle} ontag={(t) => select('tag:' + t)} onview={recordHistory} />
{/each}
</div>
{:else}
@@ -465,6 +497,8 @@
.hist a { color: var(--ink); }
.hist a:hover { color: var(--accent-deep); }
.hsrc { margin-left: auto; color: var(--muted); font-size: 0.78rem; white-space: nowrap; }
.hist .hx { background: none; border: none; color: var(--muted); font-size: 1.15rem; line-height: 1; cursor: pointer; padding: 0 2px; }
.hist .hx:hover { color: var(--accent-deep); }
.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(--accent-deep); }
+11
View File
@@ -497,6 +497,17 @@ def create_app() -> FastAPI:
conn.commit()
return {"ok": True}
@app.delete("/api/history/{article_id}")
def remove_history(article_id: int, request: Request) -> dict:
with get_conn() as conn:
user = _require_user(conn, request)
conn.execute(
"DELETE FROM user_history WHERE user_id = ? AND article_id = ?",
(user["id"], article_id),
)
conn.commit()
return {"ok": True}
@app.post("/api/import")
def import_local(body: ImportBody, request: Request) -> dict:
"""Fold this device's anonymous history/saved into the account (one-time)."""
+3
View File
@@ -67,3 +67,6 @@ def test_history_and_import(client):
tc.post("/api/import", json={"seen": [3], "saved": [1, 999]})
assert {a["id"] for a in tc.get("/api/history").json()["items"]} == {1, 2, 3}
assert tc.get("/api/saved/ids").json() == [1]
# granular removal from history
tc.delete("/api/history/2")
assert {a["id"] for a in tc.get("/api/history").json()["items"]} == {1, 3}