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
+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():