From 38889f76e5626fde9124568bc89cd8eb96fd569d Mon Sep 17 00:00:00 2001 From: jay Date: Mon, 8 Jun 2026 08:30:33 -0400 Subject: [PATCH] Source feeds: click a source to see its publication feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Click a source name on any card → a feed of just that source's articles, newest-first, still accepted / non-duplicate / boundary-filtered (the calm promise isn't bypassed). A natural way to follow a publication's feel. * queries.feed + /api/feed: source_id filter; Article output gains source_id. * Frontend: source label is a button → transient 'source:' view (like 'tag:'), rendered in the feed grid with Load more, header = source name. * Ad-hoc, not a pinned lane. Foundation for a future source page (metadata) + Follow; shareable /source/ route and source_view analytics come then. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/lib/components/ArticleCard.svelte | 15 ++++++++++-- frontend/src/routes/+page.svelte | 23 +++++++++++++++---- goodnews/api.py | 7 ++++-- goodnews/queries.py | 5 ++++ tests/test_feed_sort.py | 13 +++++++++++ 5 files changed, 55 insertions(+), 8 deletions(-) diff --git a/frontend/src/lib/components/ArticleCard.svelte b/frontend/src/lib/components/ArticleCard.svelte index 887bfd0..f82b6db 100644 --- a/frontend/src/lib/components/ArticleCard.svelte +++ b/frontend/src/lib/components/ArticleCard.svelte @@ -2,7 +2,7 @@ import { auth, savedIds, toggleSave } from '$lib/auth.svelte.js'; import { track } from '$lib/analytics.js'; - let { article, onaction, onreplace, ontag, onimageerror, onview, hero = false, thumb = false } = $props(); + let { article, onaction, onreplace, ontag, onsource, onimageerror, onview, hero = false, thumb = false } = $props(); function opened() { // Records history; the /a page itself fires the summary_viewed event on load. @@ -122,7 +122,13 @@ {/each} -
{article.source}
+
+ {#if onsource && article.source_id} + + {:else} + {article.source} + {/if} +

{article.title}

@@ -217,6 +223,11 @@ .tile .pillrow { align-self: center; } /* Source on its own line below the tags, left-justified, for uniformity. */ .src { color: var(--muted); font-size: 0.78rem; margin: -2px 0 2px; } + .srclink { + background: none; border: none; padding: 0; font: inherit; color: var(--muted); + cursor: pointer; border-bottom: 1px dotted transparent; + } + .srclink:hover { color: var(--accent-deep); border-bottom-color: var(--accent-soft); } h3 { font-size: 1.18rem; } h3 a:hover { color: var(--accent-deep); } diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 61f8b3b..ccfafa1 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -20,7 +20,8 @@ let families = $state([]); let lanePool = $state(null); // /api/lanes: { pinned, default, groups } let showLanes = $state(false); - let selected = $state('today'); // 'today' | a mood key | a topic key | 'tag:' + let selected = $state('today'); // 'today' | mood | topic | 'tag:' | 'source:' + let currentSource = $state(null); // {id, name} for a 'source:' view's header let brief = $state(null); let heroIdx = $state(0); let feed = $state([]); @@ -98,12 +99,14 @@ ); let viewLabel = $derived( selected === 'today' ? 'Highlights from Today' + : selected.startsWith('source:') ? (currentSource?.name ?? 'Source') : selected === 'latest' ? 'Latest' : currentTag ? humanize(currentTag) : (currentMood?.label ?? cap(currentTopic?.key) ?? '') ); let viewSubtitle = $derived( selected === 'today' ? localDateLabel(brief) + : selected.startsWith('source:') ? 'Latest from this source' : selected === 'latest' ? 'Freshest calm reads — newest first' : currentTag ? (tagFamily?.description ?? '') : (currentMood?.description ?? currentTopic?.description ?? '') @@ -195,10 +198,22 @@ const q = P.param(prefs.data); return `/api/feed?limit=${PAGE}&offset=${offset}&tag=${encodeURIComponent(key.slice(4))}${q ? '&' + q : ''}${exq}`; } + if (key.startsWith('source:')) { + // A publication feed: this source's articles, newest first, still + // accepted/non-duplicate/boundary-filtered. + const q = P.param(prefs.data); + return `/api/feed?source_id=${encodeURIComponent(key.slice(7))}&sort=latest&limit=${PAGE}&offset=${offset}${q ? '&' + q : ''}${exq}`; + } const q = P.param(P.merge(prefs.data, viewFilter(key))); return `/api/feed?limit=${PAGE}&offset=${offset}${q ? '&' + q : ''}${exq}`; } + // Clicking a source name opens its publication feed; stash the name for the header. + function selectSource(id, name) { + currentSource = { id, name }; + select('source:' + id); + } + async function select(key, fresh = false) { selected = key; error = ''; @@ -346,11 +361,11 @@ {#if selected === 'today'} {#if brief?.items?.length}
- select('tag:' + t)} onview={record} onimageerror={heroImageFailed} /> + select('tag:' + t)} onsource={selectSource} onview={record} onimageerror={heroImageFailed} /> {#if restArticles.length}
{#each restArticles as a (a.id)} - select('tag:' + t)} onview={record} /> + select('tag:' + t)} onsource={selectSource} onview={record} /> {/each}
{/if} @@ -362,7 +377,7 @@ {:else if feed.length}
{#each feed as a (a.id)} - select('tag:' + t)} onview={record} /> + select('tag:' + t)} onsource={selectSource} onview={record} /> {/each}
{#if !feedDone} diff --git a/goodnews/api.py b/goodnews/api.py index 8cfc99f..be4b874 100644 --- a/goodnews/api.py +++ b/goodnews/api.py @@ -241,6 +241,7 @@ class Article(BaseModel): image_url: str | None = None published_at: str | None = None source: str + source_id: int | None = None topic: str | None = None flavor: str | None = None accepted: bool @@ -265,6 +266,7 @@ class Article(BaseModel): image_url=row.get("image_url"), published_at=row.get("published_at"), source=row["source_name"], + source_id=row.get("source_id"), topic=row.get("topic"), flavor=row.get("flavor"), accepted=bool(row.get("accepted")), @@ -921,6 +923,7 @@ def create_app() -> FastAPI: prefs: str | None = Query(None), exclude: str = Query("", description="comma-separated article ids the reader has dismissed"), tag: str | None = Query(None, description="grouping tag to browse"), + source_id: int | None = Query(None, ge=1, description="show only this source's articles"), sort: str = Query("ranked", pattern="^(ranked|latest)$", description="ranked (best-first) or latest (newest-first)"), ) -> FeedResponse: if topic and topic.lower() not in TOPICS: @@ -940,14 +943,14 @@ def create_app() -> FastAPI: fetch_n = min(2000, (offset + limit) * 4 + 50 + len(excl)) raw = queries.feed( conn, topic=topic, flavor=flavor, accepted_only=accepted_only, - limit=fetch_n, offset=0, tag=tag, sort=sort, **kw, + limit=fetch_n, offset=0, tag=tag, source_id=source_id, sort=sort, **kw, ) kept = [a for a in filter_articles(raw, fp, now) if a["id"] not in excl] rows = kept[offset : offset + limit] else: rows = queries.feed( conn, topic=topic, flavor=flavor, accepted_only=accepted_only, - limit=limit, offset=offset, tag=tag, sort=sort, **kw, + limit=limit, offset=offset, tag=tag, source_id=source_id, sort=sort, **kw, ) # Keep the top of a browse view readable: stable-sort paywalled items # below readable ones (composite order preserved within each group). diff --git a/goodnews/queries.py b/goodnews/queries.py index c86b602..0b2d1e3 100644 --- a/goodnews/queries.py +++ b/goodnews/queries.py @@ -25,6 +25,7 @@ _ARTICLE_COLUMNS = f""" a.canonical_url, a.published_at, a.image_url, + a.source_id, src.name AS source_name, s.topic, s.flavor, @@ -57,6 +58,7 @@ def feed( max_cortisol: int | None = None, max_ragebait: int | None = None, tag: str | None = None, + source_id: int | None = None, sort: str = "ranked", ) -> list[dict]: """Return articles with categorical filters applied in SQL. @@ -106,6 +108,9 @@ def feed( if tag: clauses.append("EXISTS (SELECT 1 FROM article_tags at WHERE at.article_id = a.id AND at.tag = ?)") params.append(tag.lower()) + if source_id: + clauses.append("a.source_id = ?") + params.append(source_id) where = "WHERE " + " AND ".join(clauses) params.extend([limit, offset]) diff --git a/tests/test_feed_sort.py b/tests/test_feed_sort.py index 7c3131a..18ce9b1 100644 --- a/tests/test_feed_sort.py +++ b/tests/test_feed_sort.py @@ -25,3 +25,16 @@ def test_latest_sorts_strictly_by_recency(tmp_path): latest = [a["id"] for a in queries.feed(c, sort="latest")] assert latest == [2, 1, 3] # newest → oldest, regardless of insert order + + +def test_feed_source_id_filters_to_one_source(tmp_path): + c = connect(str(tmp_path / "t.db")); init_db(c) + c.execute("INSERT INTO sources (id,name,feed_url) VALUES (1,'A','http://a/f'),(2,'B','http://b/f')") + for aid, sid in [(1, 1), (2, 2), (3, 1)]: + c.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash,published_at) " + "VALUES (?,?,?,?,?,'2026-01-01T00:00:00')", (aid, sid, f"u{aid}", f"T{aid}", f"h{aid}")) + c.execute("INSERT INTO article_scores (article_id,accepted) VALUES (?,1)", (aid,)) + c.commit() + ids = {a["id"] for a in queries.feed(c, source_id=1)} + assert ids == {1, 3} # only source A + assert all(a["source_id"] == 1 for a in queries.feed(c, source_id=1))