diff --git a/frontend/src/lib/components/BottomNav.svelte b/frontend/src/lib/components/BottomNav.svelte index dd65fd8..4f08d47 100644 --- a/frontend/src/lib/components/BottomNav.svelte +++ b/frontend/src/lib/components/BottomNav.svelte @@ -1,14 +1,18 @@ - Today + Highlights + + + + Latest diff --git a/frontend/src/lib/components/LanePicker.svelte b/frontend/src/lib/components/LanePicker.svelte index 43b3216..c09359e 100644 --- a/frontend/src/lib/components/LanePicker.svelte +++ b/frontend/src/lib/components/LanePicker.svelte @@ -26,10 +26,12 @@ {#snippet body()} Your lanes - Pick the quick-access lanes above the feed. Today always stays β choose the rest. Changes apply right away. + Highlights and Latest always stay β choose the rest. Changes apply right away. - Today π + {#each pool?.pinned ?? [] as p (p.key)} + {p.label} π + {/each} {#each pool?.groups ?? [] as g (g.name)} diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index c43e413..292e4dc 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -24,6 +24,8 @@ let brief = $state(null); let heroIdx = $state(0); let feed = $state([]); + let feedDone = $state(false); // no more pages for the current feed view + let loadingMore = $state(false); let showSignIn = $state(false); let showSaved = $state(false); // Saved flyout let loading = $state(true); @@ -96,24 +98,28 @@ ); let viewLabel = $derived( selected === 'today' ? 'Highlights from Today' - : currentTag ? humanize(currentTag) - : (currentMood?.label ?? cap(currentTopic?.key) ?? '') + : selected === 'latest' ? 'Latest' + : currentTag ? humanize(currentTag) + : (currentMood?.label ?? cap(currentTopic?.key) ?? '') ); let viewSubtitle = $derived( selected === 'today' ? localDateLabel(brief) - : currentTag ? (tagFamily?.description ?? '') - : (currentMood?.description ?? currentTopic?.description ?? '') + : selected === 'latest' ? 'Freshest calm reads β newest first' + : currentTag ? (tagFamily?.description ?? '') + : (currentMood?.description ?? currentTopic?.description ?? '') + ); + let activeTab = $derived( + selected === 'today' ? 'today' : selected === 'latest' ? 'latest' : 'browse' ); - let activeTab = $derived(selected === 'today' ? 'today' : 'browse'); - // Customizable nav rail: Today is always first, then the reader's pinned - // lanes (or the default set if they've never customized). Resolve each key - // to its {label, description} from the pool. + // Customizable nav rail: the pinned lanes (Highlights + Latest) are always + // first, then the reader's chosen lanes (or the default set if they've never + // customized). Resolve each key to its {label, description} from the pool. let laneMap = $derived( new Map( lanePool ? [ - [lanePool.pinned.key, lanePool.pinned], + ...lanePool.pinned.map((p) => [p.key, p]), ...lanePool.groups.flatMap((g) => g.lanes.map((l) => [l.key, l])), ] : [] @@ -123,14 +129,15 @@ prefs.data.lanes?.length ? prefs.data.lanes : (lanePool?.default ?? []) ); let navLanes = $derived( - lanePool ? [lanePool.pinned, ...pinnedLaneKeys.map((k) => laneMap.get(k)).filter(Boolean)] : [] + lanePool ? [...lanePool.pinned, ...pinnedLaneKeys.map((k) => laneMap.get(k)).filter(Boolean)] : [] ); function saveLanes(keys) { prefs.data.lanes = keys; persistPrefs(); - // If the reader unpinned the lane they're currently viewing, fall back home. - if (selected !== 'today' && !keys.includes(selected)) select('today'); + // If the reader unpinned the lane they're viewing, fall back to Highlights. + // (The pinned Highlights/Latest lanes are never in `keys`, so don't bounce.) + if (selected !== 'today' && selected !== 'latest' && !keys.includes(selected)) select('today'); } // Hero is the only image slot; if its image won't load, promote the next one. @@ -173,22 +180,36 @@ markDisplayed(brief.items); } + // Build a /api/feed URL for a lane view at a given page offset. 'latest' is + // the chronological firehose (sort=latest); 'tag:x' browses a grouping tag; + // anything else resolves through its mood/topic filter. + const PAGE = 24; + function feedUrl(key, offset) { + const ex = Array.from(dismissed).join(','); + const exq = ex ? `&exclude=${ex}` : ''; + if (key === 'latest') { + const q = P.param(prefs.data); + return `/api/feed?sort=latest&limit=${PAGE}&offset=${offset}${q ? '&' + q : ''}${exq}`; + } + if (key.startsWith('tag:')) { + const q = P.param(prefs.data); + return `/api/feed?limit=${PAGE}&offset=${offset}&tag=${encodeURIComponent(key.slice(4))}${q ? '&' + q : ''}${exq}`; + } + const q = P.param(P.merge(prefs.data, viewFilter(key))); + return `/api/feed?limit=${PAGE}&offset=${offset}${q ? '&' + q : ''}${exq}`; + } + async function select(key, fresh = false) { selected = key; error = ''; + feedDone = false; try { if (key === 'today') { await loadToday(fresh); - } else if (key.startsWith('tag:')) { - const tag = key.slice(4); - const q = P.param(prefs.data); - const ex = Array.from(dismissed).join(','); - feed = (await getJSON(`/api/feed?limit=24&tag=${encodeURIComponent(tag)}${q ? '&' + q : ''}${ex ? '&exclude=' + ex : ''}`)).items; - markDisplayed(feed); } else { - const q = P.param(P.merge(prefs.data, viewFilter(key))); - const ex = Array.from(dismissed).join(','); - feed = (await getJSON(`/api/feed?limit=24${q ? '&' + q : ''}${ex ? '&exclude=' + ex : ''}`)).items; + const items = (await getJSON(feedUrl(key, 0))).items; + feed = items; + feedDone = items.length < PAGE; markDisplayed(feed); } } catch (e) { @@ -197,6 +218,25 @@ if (typeof window !== 'undefined') window.scrollTo({ top: 0, behavior: 'smooth' }); } + // "Load more" for any feed view (Latest, topics, tags, moods): fetch the next + // page at the current length and append, de-duping against what's shown. + async function loadMore() { + if (loadingMore || feedDone || selected === 'today') return; + loadingMore = true; + try { + const items = (await getJSON(feedUrl(selected, feed.length))).items; + const have = new Set(feed.map((a) => a.id)); + const fresh = items.filter((a) => !have.has(a.id)); + feed = [...feed, ...fresh]; + feedDone = items.length < PAGE; + markDisplayed(fresh); + } catch { + flash('Could not load more just now.'); + } finally { + loadingMore = false; + } + } + const MIX_EVENT = { notToday: 'not_today', lessLikeThis: 'less_like_this', alwaysHide: 'hide_topic' }; function applyAction(kind, value) { applyPrefAction(kind, value); // updates + persists + syncs to account @@ -325,6 +365,15 @@ select('tag:' + t)} onview={record} /> {/each} + {#if !feedDone} + + + {loadingMore ? 'Loadingβ¦' : 'Load more'} + + + {:else} + β¦ you're all caught up β¦ + {/if} {:else} Nothing here right now β try another, or ease a boundary. {/if} @@ -354,7 +403,7 @@ {/if} - select('today')} onBrowse={browse} onYou={openAccount} user={auth.user} /> + select('today')} onLatest={() => select('latest')} onBrowse={browse} onYou={openAccount} user={auth.user} /> diff --git a/goodnews/api.py b/goodnews/api.py index 9b72ca1..8cfc99f 100644 --- a/goodnews/api.py +++ b/goodnews/api.py @@ -921,6 +921,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"), + sort: str = Query("ranked", pattern="^(ranked|latest)$", description="ranked (best-first) or latest (newest-first)"), ) -> FeedResponse: if topic and topic.lower() not in TOPICS: raise HTTPException(400, f"unknown topic: {topic}") @@ -939,14 +940,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, **kw, + limit=fetch_n, offset=0, tag=tag, 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, **kw, + limit=limit, offset=offset, tag=tag, 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/lanes.py b/goodnews/lanes.py index 5f2d317..1531153 100644 --- a/goodnews/lanes.py +++ b/goodnews/lanes.py @@ -18,8 +18,12 @@ from __future__ import annotations from .moods import MOODS from .taxonomy import ALLOWED_TAGS, TOPICS -# The lane pinned first, always β never user-removable. -PINNED = {"key": "today", "label": "Today", "description": "The day's good things."} +# The lanes pinned first, always β never user-removable. "Highlights" is the +# curated daily brief (key 'today'); "Latest" is the chronological accepted feed. +PINNED = [ + {"key": "today", "label": "Highlights", "description": "The day's curated good things."}, + {"key": "latest", "label": "Latest", "description": "Freshest calm reads, newest first."}, +] # What a reader who has never customized sees: today's curated moods, unchanged. DEFAULT_LANES: list[str] = [m["key"] for m in MOODS if m["key"] != "today"] diff --git a/goodnews/queries.py b/goodnews/queries.py index 8ab8465..c86b602 100644 --- a/goodnews/queries.py +++ b/goodnews/queries.py @@ -57,8 +57,13 @@ def feed( max_cortisol: int | None = None, max_ragebait: int | None = None, tag: str | None = None, + sort: str = "ranked", ) -> list[dict]: - """Return ranked articles with categorical filters applied in SQL. + """Return articles with categorical filters applied in SQL. + + sort="ranked" (default) is best-first by the composite rank; sort="latest" + is pure recency (newest first) for the chronological "Latest" feed. Both + stay accepted-only and respect the same boundaries. Categorical filters (topic/flavor include & mute, cortisol/ragebait ceilings) must be applied here, not after ranking β otherwise low-ranked-but-matching @@ -105,6 +110,11 @@ def feed( where = "WHERE " + " AND ".join(clauses) params.extend([limit, offset]) + order_by = ( + "COALESCE(a.published_at, a.discovered_at) DESC, rank_score DESC" + if sort == "latest" + else "rank_score DESC, COALESCE(a.published_at, a.discovered_at) DESC" + ) rows = conn.execute( f""" SELECT {_ARTICLE_COLUMNS} @@ -112,7 +122,7 @@ def feed( JOIN sources src ON src.id = a.source_id JOIN article_scores s ON s.article_id = a.id {where} - ORDER BY rank_score DESC, COALESCE(a.published_at, a.discovered_at) DESC + ORDER BY {order_by} LIMIT ? OFFSET ? """, params, diff --git a/tests/test_feed_sort.py b/tests/test_feed_sort.py new file mode 100644 index 0000000..7c3131a --- /dev/null +++ b/tests/test_feed_sort.py @@ -0,0 +1,27 @@ +from goodnews.db import connect, init_db +from goodnews import queries + + +def _article(c, aid, *, when): + c.execute( + "INSERT INTO articles (id, source_id, canonical_url, title, url_hash, published_at) " + "VALUES (?, 1, ?, ?, ?, ?)", + (aid, f"http://s/{aid}", f"T{aid}", f"h{aid}", when), + ) + c.execute( + "INSERT INTO article_scores (article_id, accepted, constructive_score) VALUES (?, 1, 5)", + (aid,), + ) + + +def test_latest_sorts_strictly_by_recency(tmp_path): + c = connect(str(tmp_path / "t.db")); init_db(c) + c.execute("INSERT INTO sources (id, name, feed_url) VALUES (1, 'S', 'http://s/f')") + # Insert out of order; the dates are what should drive 'latest'. + _article(c, 1, when="2026-03-01T00:00:00") + _article(c, 2, when="2026-06-01T00:00:00") # newest + _article(c, 3, when="2026-01-01T00:00:00") # oldest + c.commit() + + latest = [a["id"] for a in queries.feed(c, sort="latest")] + assert latest == [2, 1, 3] # newest β oldest, regardless of insert order diff --git a/tests/test_lanes.py b/tests/test_lanes.py index c88a177..e0dc31c 100644 --- a/tests/test_lanes.py +++ b/tests/test_lanes.py @@ -3,12 +3,13 @@ from goodnews.lanes import build_lane_pool, known_lane_keys, DEFAULT_LANES def test_pool_shape_and_pinned(): pool = build_lane_pool({"science": 100}, {"space": 200, "food": 0, "innovation": 99}) - assert pool["pinned"]["key"] == "today" + pinned_keys = [p["key"] for p in pool["pinned"]] + assert pinned_keys == ["today", "latest"] # Highlights + Latest, always first assert pool["default"] == DEFAULT_LANES names = [g["name"] for g in pool["groups"]] assert names == ["Moods", "Topics", "Discovery"] - # 'today' is pinned, never part of the selectable pool. - assert "today" not in known_lane_keys(pool) + # pinned lanes are never part of the selectable pool. + assert "today" not in known_lane_keys(pool) and "latest" not in known_lane_keys(pool) def test_topics_are_bare_keys_tags_are_prefixed():
Pick the quick-access lanes above the feed. Today always stays β choose the rest. Changes apply right away.
Highlights and Latest always stay β choose the rest. Changes apply right away.
β¦ you're all caught up β¦
Nothing here right now β try another, or ease a boundary.