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