From d8d665ee35ffd50a78ca5d880cfe657c48e66c94 Mon Sep 17 00:00:00 2001 From: jay Date: Sun, 31 May 2026 12:56:57 +0000 Subject: [PATCH] Crisp hero (prefer og:image), 7-card Highlights, no-recycle Replace + session History MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Hero blur fix: brief enrichment now prefers a page's og:image even when a feed thumbnail exists (feed thumbs are often tiny; the hero is shown large). Verified: BBC hero upgrades to the 1024px share image, ScienceDaily to 1920px. - Today is now 'Highlights from Today' — hero + 6 (brief size 7), which also makes the secondary grid a balanced 3+3 instead of an orphaned 3+1. - Replace now excludes every article seen this session (a client-side seen-set), so it never cycles back to something already shown. - New session History panel (this tab only, no account): lists everything seen, including swapped-away stories, so they stay recoverable. Persistent history/favorites are tabled for sign-in later. Tests: og:image upgrade of an existing feed image (86 total). Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/routes/+page.svelte | 115 ++++++++++++++++++++++--------- goodnews/briefs.py | 4 +- goodnews/cli.py | 6 +- goodnews/enrich.py | 5 +- ideas.md | 2 + tests/test_enrich.py | 16 +++++ 6 files changed, 111 insertions(+), 37 deletions(-) diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 378fad3..7c50a6c 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -12,39 +12,52 @@ let feed = $state([]); let userPrefs = $state(P.blank()); let showBoundaries = $state(false); + let showHistory = $state(false); 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(); + let history = $state([]); + function remember(items) { + for (const a of items || []) { + if (a && !seenIds.has(a.id)) { + seenIds.add(a.id); + history.unshift(a); + } + } + } + let filtersOn = $derived(P.active(userPrefs)); let current = $derived(moods.find((m) => m.key === selected)); - let viewLabel = $derived(current?.label ?? 'Today'); + let viewLabel = $derived(selected === 'today' ? 'Highlights from Today' : (current?.label ?? '')); let viewSubtitle = $derived( - selected === 'today' - ? brief?.brief_date - ? `Five good things · ${brief.brief_date}` - : 'Five good things today' - : (current?.description ?? '') + selected === 'today' ? (brief?.brief_date ?? '') : (current?.description ?? '') ); - function feedUrl(moodKey, limit) { - const mood = moods.find((m) => m.key === moodKey); - const merged = P.merge(userPrefs, mood?.filter ?? {}); - const q = P.param(merged); - return `/api/feed?limit=${limit}${q ? '&' + q : ''}`; - } - function briefUrl() { - const q = P.param(userPrefs); - return `/api/brief?limit=5${q ? '&' + q : ''}`; + function mergedParam() { + const merged = P.merge(userPrefs, current?.filter ?? {}); + return P.param(merged); } async function select(key) { selected = key; error = ''; try { - // Today = just the day's highlights. Other moods reveal that category only - // when chosen (categories live behind their selection, not on the home). - if (key === 'today') brief = await getJSON(briefUrl()); - else feed = (await getJSON(feedUrl(key, 24))).items; + // Today = the day's highlights (hero + six). Other moods reveal that + // category only when chosen. + if (key === 'today') { + const q = P.param(userPrefs); + brief = await getJSON(`/api/brief?limit=7${q ? '&' + q : ''}`); + remember(brief.items); + } else { + const mood = moods.find((m) => m.key === key); + const q = P.param(P.merge(userPrefs, mood?.filter ?? {})); + feed = (await getJSON(`/api/feed?limit=24${q ? '&' + q : ''}`)).items; + remember(feed); + } } catch (e) { error = 'Something went quiet — could not reach the feed.'; } @@ -70,11 +83,11 @@ async function replaceArticle(article) { const list = selected === 'today' ? brief?.items : feed; if (!list) return; - const shown = list.map((a) => a.id).join(','); const isHero = selected === 'today' && list[0]?.id === article.id; - const merged = P.merge(userPrefs, current?.filter ?? {}); - const q = P.param(merged); - const url = `/api/replacement?exclude=${shown}&avoid_paywall=true${isHero ? '&gentle=true' : ''}${q ? '&' + q : ''}`; + // Exclude everything seen this session, 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 : ''}`; let repl; try { repl = await getJSON(url); @@ -83,9 +96,10 @@ return; } if (!repl) { - flash('Nothing else to swap in right now — try easing a boundary.'); + flash("That's everything fresh for now — nothing new to swap in."); return; } + remember([repl]); if (selected === 'today') { const i = brief.items.findIndex((a) => a.id === article.id); if (i >= 0) { @@ -118,7 +132,8 @@ {/if}
- +
@@ -127,6 +142,28 @@ (showBoundaries = false)} /> {/if} +{#if showHistory} +
+
+

This session

+ +
+

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.)

+ {#if history.length} +
    + {#each history as a (a.id)} +
  • + {a.title} + {a.source} +
  • + {/each} +
+ {:else} +

Nothing yet — your seen stories will appear here.

+ {/if} +
+{/if} + {#if notice}

{notice}

{/if} @@ -156,7 +193,7 @@

✦ that's the good news for today ✦

{:else} -

No brief yet today — try a calmer filter, or check back soon.

+

No highlights yet today — try a calmer filter, or check back soon.

{/if} {:else if feed.length}
@@ -171,13 +208,13 @@ {/if}