Add a permanent "Latest" lane beside "Highlights"

Restructure the nav around two permanent lanes, then the reader's chosen ones:
"Highlights" (the curated daily brief — formerly "Today") and "Latest" (the
freshest accepted stories, newest-first). Now that the gate is tight, a
chronological "incoming" feed is safe to expose.

* feed(): new sort="latest" (pure recency) alongside the default best-first
  rank; /api/feed exposes sort=ranked|latest (validated). Still accepted-only
  and boundary-respecting either way.
* lanes.py: two pinned lanes (Highlights + Latest) instead of one.
* Home: "Latest" view + "Load more" pagination for every feed view (offset-
  paged, de-duped). Mobile bottom bar gains a Latest tab.
* LanePicker shows both pinned lanes; nav rail renders them first.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-06 15:56:48 -04:00
parent d87347b032
commit c25e14ed6a
8 changed files with 143 additions and 37 deletions
+8 -4
View File
@@ -1,14 +1,18 @@
<script>
// Mobile-only primary navigation. Today = the brief, Browse = mood/topic
// discovery, You = account + personal controls (shows the user's avatar in).
// Mobile-only primary navigation. Highlights = the brief, Latest = the
// chronological feed, Browse = mood/topic discovery, You = account.
import Avatar from './Avatar.svelte';
let { active = 'today', onToday, onBrowse, onYou, user = null } = $props();
let { active = 'today', onToday, onLatest, onBrowse, onYou, user = null } = $props();
</script>
<nav class="bottomnav" aria-label="Primary">
<button class:active={active === 'today'} onclick={onToday}>
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4.2" fill="none" stroke="currentColor" stroke-width="1.8" /><path d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6l1.4 1.4M17 17l1.4 1.4M18.4 5.6L17 7M7 17l-1.4 1.4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" /></svg>
<span>Today</span>
<span>Highlights</span>
</button>
<button class:active={active === 'latest'} onclick={onLatest}>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h10" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" /></svg>
<span>Latest</span>
</button>
<button class:active={active === 'browse'} onclick={onBrowse}>
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.8" /><path d="M15.5 8.5l-2 5-5 2 2-5 5-2z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" /></svg>
@@ -26,10 +26,12 @@
{#snippet body()}
<h2>Your lanes</h2>
<p class="sub">Pick the quick-access lanes above the feed. <strong>Today</strong> always stays — choose the rest. Changes apply right away.</p>
<p class="sub"><strong>Highlights</strong> and <strong>Latest</strong> always stay — choose the rest. Changes apply right away.</p>
<div class="pinned-row">
<span class="chip pinned" title="Always shown">Today <span class="lock">📌</span></span>
{#each pool?.pinned ?? [] as p (p.key)}
<span class="chip pinned" title="Always shown">{p.label} <span class="lock">📌</span></span>
{/each}
</div>
{#each pool?.groups ?? [] as g (g.name)}
+79 -22
View File
@@ -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 @@
<ArticleCard article={a} onaction={applyAction} onreplace={replaceArticle} ontag={(t) => select('tag:' + t)} onview={record} />
{/each}
</div>
{#if !feedDone}
<div class="loadmore">
<button onclick={loadMore} disabled={loadingMore}>
{loadingMore ? 'Loading…' : 'Load more'}
</button>
</div>
{:else}
<p class="endcap rise">✦ you're all caught up ✦</p>
{/if}
{:else}
<p class="muted center pad">Nothing here right now — try another, or ease a boundary.</p>
{/if}
@@ -354,7 +403,7 @@
{/if}
</main>
<BottomNav active={activeTab} onToday={() => select('today')} onBrowse={browse} onYou={openAccount} user={auth.user} />
<BottomNav active={activeTab} onToday={() => select('today')} onLatest={() => select('latest')} onBrowse={browse} onYou={openAccount} user={auth.user} />
<style>
main.container { padding-top: 6px; padding-bottom: 40px; min-height: 60vh; }
@@ -395,4 +444,12 @@
text-align: center; color: var(--muted); font-family: var(--serif);
font-style: italic; margin: 40px 0 10px; letter-spacing: 0.02em;
}
.loadmore { display: flex; justify-content: center; margin: 30px 0 6px; }
.loadmore button {
background: var(--surface); border: 1px solid var(--line); color: var(--accent-deep);
border-radius: 999px; padding: 10px 28px; font-size: 0.92rem; cursor: pointer;
transition: all 0.14s ease;
}
.loadmore button:hover { border-color: var(--accent); background: var(--accent-soft); }
.loadmore button:disabled { opacity: 0.6; cursor: default; }
</style>
+3 -2
View File
@@ -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).
+6 -2
View File
@@ -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"]
+12 -2
View File
@@ -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,
+27
View File
@@ -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
+4 -3
View File
@@ -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():