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:
@@ -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)}
|
||||
|
||||
@@ -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'
|
||||
: selected === 'latest' ? 'Latest'
|
||||
: currentTag ? humanize(currentTag)
|
||||
: (currentMood?.label ?? cap(currentTopic?.key) ?? '')
|
||||
);
|
||||
let viewSubtitle = $derived(
|
||||
selected === 'today' ? localDateLabel(brief)
|
||||
: selected === 'latest' ? 'Freshest calm reads — newest first'
|
||||
: currentTag ? (tagFamily?.description ?? '')
|
||||
: (currentMood?.description ?? currentTopic?.description ?? '')
|
||||
);
|
||||
let activeTab = $derived(selected === 'today' ? 'today' : 'browse');
|
||||
let activeTab = $derived(
|
||||
selected === 'today' ? 'today' : selected === 'latest' ? 'latest' : '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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user