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
+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,