Crisp hero (prefer og:image), 7-card Highlights, no-recycle Replace + session History
- 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) <noreply@anthropic.com>
This commit is contained in:
@@ -12,39 +12,52 @@
|
|||||||
let feed = $state([]);
|
let feed = $state([]);
|
||||||
let userPrefs = $state(P.blank());
|
let userPrefs = $state(P.blank());
|
||||||
let showBoundaries = $state(false);
|
let showBoundaries = $state(false);
|
||||||
|
let showHistory = $state(false);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let error = $state('');
|
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 filtersOn = $derived(P.active(userPrefs));
|
||||||
let current = $derived(moods.find((m) => m.key === selected));
|
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(
|
let viewSubtitle = $derived(
|
||||||
selected === 'today'
|
selected === 'today' ? (brief?.brief_date ?? '') : (current?.description ?? '')
|
||||||
? brief?.brief_date
|
|
||||||
? `Five good things · ${brief.brief_date}`
|
|
||||||
: 'Five good things today'
|
|
||||||
: (current?.description ?? '')
|
|
||||||
);
|
);
|
||||||
|
|
||||||
function feedUrl(moodKey, limit) {
|
function mergedParam() {
|
||||||
const mood = moods.find((m) => m.key === moodKey);
|
const merged = P.merge(userPrefs, current?.filter ?? {});
|
||||||
const merged = P.merge(userPrefs, mood?.filter ?? {});
|
return P.param(merged);
|
||||||
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 : ''}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function select(key) {
|
async function select(key) {
|
||||||
selected = key;
|
selected = key;
|
||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
// Today = just the day's highlights. Other moods reveal that category only
|
// Today = the day's highlights (hero + six). Other moods reveal that
|
||||||
// when chosen (categories live behind their selection, not on the home).
|
// category only when chosen.
|
||||||
if (key === 'today') brief = await getJSON(briefUrl());
|
if (key === 'today') {
|
||||||
else feed = (await getJSON(feedUrl(key, 24))).items;
|
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) {
|
} catch (e) {
|
||||||
error = 'Something went quiet — could not reach the feed.';
|
error = 'Something went quiet — could not reach the feed.';
|
||||||
}
|
}
|
||||||
@@ -70,11 +83,11 @@
|
|||||||
async function replaceArticle(article) {
|
async function replaceArticle(article) {
|
||||||
const list = selected === 'today' ? brief?.items : feed;
|
const list = selected === 'today' ? brief?.items : feed;
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
const shown = list.map((a) => a.id).join(',');
|
|
||||||
const isHero = selected === 'today' && list[0]?.id === article.id;
|
const isHero = selected === 'today' && list[0]?.id === article.id;
|
||||||
const merged = P.merge(userPrefs, current?.filter ?? {});
|
// Exclude everything seen this session, so Replace never cycles back.
|
||||||
const q = P.param(merged);
|
const exclude = Array.from(seenIds).join(',');
|
||||||
const url = `/api/replacement?exclude=${shown}&avoid_paywall=true${isHero ? '&gentle=true' : ''}${q ? '&' + q : ''}`;
|
const q = mergedParam();
|
||||||
|
const url = `/api/replacement?exclude=${exclude}&avoid_paywall=true${isHero ? '&gentle=true' : ''}${q ? '&' + q : ''}`;
|
||||||
let repl;
|
let repl;
|
||||||
try {
|
try {
|
||||||
repl = await getJSON(url);
|
repl = await getJSON(url);
|
||||||
@@ -83,9 +96,10 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!repl) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
remember([repl]);
|
||||||
if (selected === 'today') {
|
if (selected === 'today') {
|
||||||
const i = brief.items.findIndex((a) => a.id === article.id);
|
const i = brief.items.findIndex((a) => a.id === article.id);
|
||||||
if (i >= 0) {
|
if (i >= 0) {
|
||||||
@@ -118,7 +132,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="toptools">
|
<div class="toptools">
|
||||||
<button class="boundaries" class:on={filtersOn} onclick={() => (showBoundaries = !showBoundaries)}>
|
<button class="link" class:on={history.length} onclick={() => (showHistory = !showHistory)}>History</button>
|
||||||
|
<button class="link" class:on={filtersOn} onclick={() => (showBoundaries = !showBoundaries)}>
|
||||||
{filtersOn ? 'Boundaries ·' : 'Boundaries'}
|
{filtersOn ? 'Boundaries ·' : 'Boundaries'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -127,6 +142,28 @@
|
|||||||
<BoundariesPanel prefs={userPrefs} onchange={refreshPrefs} onclose={() => (showBoundaries = false)} />
|
<BoundariesPanel prefs={userPrefs} onchange={refreshPrefs} onclose={() => (showBoundaries = false)} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if showHistory}
|
||||||
|
<section class="panel rise">
|
||||||
|
<div class="phead">
|
||||||
|
<h2>This session</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>
|
||||||
|
{#if history.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}
|
||||||
|
</ul>
|
||||||
|
{:else}
|
||||||
|
<p class="empty">Nothing yet — your seen stories will appear here.</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if notice}
|
{#if notice}
|
||||||
<p class="notice rise">{notice}</p>
|
<p class="notice rise">{notice}</p>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -156,7 +193,7 @@
|
|||||||
</section>
|
</section>
|
||||||
<p class="endcap rise">✦ that's the good news for today ✦</p>
|
<p class="endcap rise">✦ that's the good news for today ✦</p>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="muted center pad">No brief yet today — try a calmer filter, or check back soon.</p>
|
<p class="muted center pad">No highlights yet today — try a calmer filter, or check back soon.</p>
|
||||||
{/if}
|
{/if}
|
||||||
{:else if feed.length}
|
{:else if feed.length}
|
||||||
<div class="grid rise">
|
<div class="grid rise">
|
||||||
@@ -171,13 +208,13 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.toptools { display: flex; justify-content: flex-end; margin: 2px 0 0; }
|
.toptools { display: flex; justify-content: flex-end; gap: 18px; margin: 2px 0 0; }
|
||||||
.boundaries {
|
.link {
|
||||||
background: none; border: none; color: var(--muted);
|
background: none; border: none; color: var(--muted);
|
||||||
font-size: 0.82rem; padding: 4px 2px; letter-spacing: 0.01em;
|
font-size: 0.82rem; padding: 4px 2px; letter-spacing: 0.01em;
|
||||||
}
|
}
|
||||||
.boundaries:hover { color: var(--sage-deep); }
|
.link:hover { color: var(--sage-deep); }
|
||||||
.boundaries.on { color: var(--sage-deep); font-weight: 600; }
|
.link.on { color: var(--sage-deep); font-weight: 600; }
|
||||||
|
|
||||||
.view-head { margin: 20px 0 20px; }
|
.view-head { margin: 20px 0 20px; }
|
||||||
.view-head h1 {
|
.view-head h1 {
|
||||||
@@ -185,12 +222,28 @@
|
|||||||
line-height: 1.05;
|
line-height: 1.05;
|
||||||
}
|
}
|
||||||
.view-head .sub { margin: 8px 0 0; color: var(--muted); font-size: 1.02rem; }
|
.view-head .sub { margin: 8px 0 0; color: var(--muted); font-size: 1.02rem; }
|
||||||
/* a quiet sage rule under the heading */
|
|
||||||
.view-head::after {
|
.view-head::after {
|
||||||
content: ''; display: block; width: 46px; height: 3px;
|
content: ''; display: block; width: 46px; height: 3px;
|
||||||
background: var(--sage); border-radius: 2px; margin-top: 14px; opacity: 0.8;
|
background: var(--sage); border-radius: 2px; margin-top: 14px; opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* History panel */
|
||||||
|
.panel {
|
||||||
|
background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
|
||||||
|
box-shadow: var(--shadow); padding: 20px 22px; margin: 8px 0 18px;
|
||||||
|
}
|
||||||
|
.phead { display: flex; align-items: baseline; justify-content: space-between; }
|
||||||
|
.phead h2 { font-size: 1.3rem; }
|
||||||
|
.close { background: none; border: none; color: var(--sage-deep); font-size: 0.85rem; text-decoration: underline; }
|
||||||
|
.reassure { margin: 4px 0 14px; color: var(--muted); font-size: 0.85rem; }
|
||||||
|
.hist { list-style: none; margin: 0; padding: 0; }
|
||||||
|
.hist li { padding: 8px 0; border-bottom: 1px solid var(--line); display: flex; gap: 12px; align-items: baseline; }
|
||||||
|
.hist li:last-child { border-bottom: none; }
|
||||||
|
.hist a { color: var(--ink); }
|
||||||
|
.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; }
|
||||||
|
|
||||||
.notice {
|
.notice {
|
||||||
text-align: center; color: var(--sage-deep); background: var(--sage-soft);
|
text-align: center; color: var(--sage-deep); background: var(--sage-soft);
|
||||||
border-radius: 999px; padding: 8px 16px; margin: 10px auto 0; width: fit-content;
|
border-radius: 999px; padding: 8px 16px; margin: 10px auto 0; width: fit-content;
|
||||||
|
|||||||
+2
-2
@@ -9,7 +9,7 @@ from .paywall import is_paywalled
|
|||||||
def build_daily_brief(
|
def build_daily_brief(
|
||||||
conn: sqlite3.Connection,
|
conn: sqlite3.Connection,
|
||||||
brief_date: str | None = None,
|
brief_date: str | None = None,
|
||||||
limit: int = 5,
|
limit: int = 7,
|
||||||
replace: bool = False,
|
replace: bool = False,
|
||||||
window_days: int = 3,
|
window_days: int = 3,
|
||||||
) -> int:
|
) -> int:
|
||||||
@@ -22,7 +22,7 @@ def build_daily_brief(
|
|||||||
|
|
||||||
brief_id = conn.execute(
|
brief_id = conn.execute(
|
||||||
"INSERT INTO daily_briefs (brief_date, title) VALUES (?, ?)",
|
"INSERT INTO daily_briefs (brief_date, title) VALUES (?, ?)",
|
||||||
(target_date, f"Five Good Things Today - {target_date}"),
|
(target_date, f"Highlights from Today - {target_date}"),
|
||||||
).lastrowid
|
).lastrowid
|
||||||
|
|
||||||
rows = _candidate_articles(conn, target_date, window_days)
|
rows = _candidate_articles(conn, target_date, window_days)
|
||||||
|
|||||||
+3
-3
@@ -139,7 +139,7 @@ def main() -> None:
|
|||||||
|
|
||||||
brief_parser = subparsers.add_parser("build-brief", help="Build/freeze a daily brief")
|
brief_parser = subparsers.add_parser("build-brief", help="Build/freeze a daily brief")
|
||||||
brief_parser.add_argument("--date", help="Brief date in YYYY-MM-DD format; defaults to today")
|
brief_parser.add_argument("--date", help="Brief date in YYYY-MM-DD format; defaults to today")
|
||||||
brief_parser.add_argument("--limit", type=int, default=5)
|
brief_parser.add_argument("--limit", type=int, default=7)
|
||||||
brief_parser.add_argument("--replace", action="store_true")
|
brief_parser.add_argument("--replace", action="store_true")
|
||||||
|
|
||||||
show_brief_parser = subparsers.add_parser("show-brief", help="Show a stored daily brief")
|
show_brief_parser = subparsers.add_parser("show-brief", help="Show a stored daily brief")
|
||||||
@@ -446,7 +446,7 @@ def _run_cycle_locked(conn: sqlite3.Connection, args: argparse.Namespace) -> Non
|
|||||||
if not args.no_brief:
|
if not args.no_brief:
|
||||||
today = date.today().isoformat()
|
today = date.today().isoformat()
|
||||||
try:
|
try:
|
||||||
brief_id = build_daily_brief(conn, brief_date=today, limit=5, replace=True)
|
brief_id = build_daily_brief(conn, brief_date=today, limit=7, replace=True)
|
||||||
found = enrich_brief_images(conn, today)
|
found = enrich_brief_images(conn, today)
|
||||||
print(f"brief: rebuilt {today} (id {brief_id}); {found} hero image(s) enriched")
|
print(f"brief: rebuilt {today} (id {brief_id}); {found} hero image(s) enriched")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -672,7 +672,7 @@ def print_brief(rows: list[sqlite3.Row]) -> None:
|
|||||||
print("No brief items found.")
|
print("No brief items found.")
|
||||||
return
|
return
|
||||||
date = rows[0]["brief_date"]
|
date = rows[0]["brief_date"]
|
||||||
print(f"Five Good Things Today - {date}")
|
print(f"Highlights from Today - {date}")
|
||||||
for row in rows:
|
for row in rows:
|
||||||
print(f"{row['rank']}. {row['title']}")
|
print(f"{row['rank']}. {row['title']}")
|
||||||
print(f" {row['source_name']} | {row['default_category']} | {row['model_name']}")
|
print(f" {row['source_name']} | {row['default_category']} | {row['model_name']}")
|
||||||
|
|||||||
+4
-1
@@ -127,13 +127,16 @@ def enrich_brief_images(conn: sqlite3.Connection, brief_date: str, fetch=fetch_o
|
|||||||
stamps image_checked_at either way so failures are not retried forever.
|
stamps image_checked_at either way so failures are not retried forever.
|
||||||
Returns how many images were newly found.
|
Returns how many images were newly found.
|
||||||
"""
|
"""
|
||||||
|
# Brief items not yet checked. We fetch even when a feed image exists,
|
||||||
|
# because feed thumbnails are often tiny and the hero is shown large — a
|
||||||
|
# page's og:image (the social-share image) is the better hero visual.
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT a.id, a.canonical_url
|
SELECT a.id, a.canonical_url
|
||||||
FROM daily_briefs b
|
FROM daily_briefs b
|
||||||
JOIN daily_brief_items bi ON bi.brief_id = b.id
|
JOIN daily_brief_items bi ON bi.brief_id = b.id
|
||||||
JOIN articles a ON a.id = bi.article_id
|
JOIN articles a ON a.id = bi.article_id
|
||||||
WHERE b.brief_date = ? AND a.image_url IS NULL AND a.image_checked_at IS NULL
|
WHERE b.brief_date = ? AND a.image_checked_at IS NULL
|
||||||
ORDER BY bi.rank
|
ORDER BY bi.rank
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
""",
|
""",
|
||||||
|
|||||||
@@ -18,3 +18,5 @@ $ I really like the coloring for the metadata highlighting in each card (The gra
|
|||||||
|
|
||||||
-l Shomehow include a daily inspirational/motivational/uplifting quote that would change each day.
|
-l Shomehow include a daily inspirational/motivational/uplifting quote that would change each day.
|
||||||
- Allow ability to forward/share articles
|
- Allow ability to forward/share articles
|
||||||
|
* Session-only history of seen/swapped-away articles, recoverable without an account [done: History panel; Replace no longer recycles seen stories]
|
||||||
|
| Persistent history + favorites with sign-in [tabled: needs accounts]
|
||||||
|
|||||||
@@ -53,3 +53,19 @@ def test_enrich_caches_failure_and_does_not_retry(conn):
|
|||||||
assert r["image_url"] is None and r["image_checked_at"] is not None # checked, cached
|
assert r["image_url"] is None and r["image_checked_at"] is not None # checked, cached
|
||||||
assert enrich_brief_images(conn, "2026-05-31", fetch=fail) == 0
|
assert enrich_brief_images(conn, "2026-05-31", fetch=fail) == 0
|
||||||
assert len(calls) == 1 # not retried once checked
|
assert len(calls) == 1 # not retried once checked
|
||||||
|
|
||||||
|
|
||||||
|
def test_enrich_upgrades_existing_feed_image(tmp_path):
|
||||||
|
# A brief item with a (small) feed image should be upgraded to og:image.
|
||||||
|
from goodnews.db import connect as _c, init_db as _i
|
||||||
|
c = _c(":memory:"); _i(c)
|
||||||
|
c.execute("INSERT INTO sources (id,name,feed_url,trust_score) VALUES (1,'S','http://s/f',5)")
|
||||||
|
c.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash,image_url) "
|
||||||
|
"VALUES (1,1,'https://bbc.com/x','t1','h1','https://bbc.com/tiny-thumb.jpg')")
|
||||||
|
c.execute("INSERT INTO daily_briefs (id,brief_date,title) VALUES (1,'2026-05-31','B')")
|
||||||
|
c.execute("INSERT INTO daily_brief_items (brief_id,article_id,rank) VALUES (1,1,1)")
|
||||||
|
c.commit()
|
||||||
|
found = enrich_brief_images(c, "2026-05-31", fetch=lambda u: "https://bbc.com/big-og.jpg")
|
||||||
|
assert found == 1
|
||||||
|
assert c.execute("SELECT image_url FROM articles WHERE id=1").fetchone()["image_url"] == "https://bbc.com/big-og.jpg"
|
||||||
|
c.close()
|
||||||
|
|||||||
Reference in New Issue
Block a user