c25e14ed6a
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>
80 lines
3.1 KiB
Python
80 lines
3.1 KiB
Python
"""Nav lanes — the customizable quick-access rail above the feed.
|
|
|
|
"Today" is always pinned (the daily brief). Past that, a reader pins whichever
|
|
lanes they like from a pool of three kinds, all of which the feed already knows
|
|
how to render as a view:
|
|
|
|
* moods — curated filter presets (see moods.py); key is the bare mood key.
|
|
* topics — the eight primary categories; key is the bare topic key.
|
|
* tags — high-volume grouping tags (Discovery); key is "tag:<slug>".
|
|
|
|
Single source of truth so the website and any future companion app agree on the
|
|
pool and the default selection. The pool's tag membership is volume-gated so we
|
|
never offer a lane that would land the reader on an empty feed.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from .moods import MOODS
|
|
from .taxonomy import ALLOWED_TAGS, TOPICS
|
|
|
|
# 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"]
|
|
|
|
# A grouping tag joins the Discovery pool once it clears this many accepted
|
|
# articles — except the curated few we always want offered (they fill forward).
|
|
_TAG_MIN_COUNT = 60
|
|
_ALWAYS_OFFER_TAGS = ("space", "food")
|
|
# Tags that duplicate a primary topic are dropped from Discovery to avoid two
|
|
# pills that mean the same thing.
|
|
_TAG_EXCLUDE = set(TOPICS)
|
|
|
|
|
|
def _humanize(slug: str) -> str:
|
|
return slug.replace("-", " ").title()
|
|
|
|
|
|
def build_lane_pool(topic_counts: dict[str, int], tag_counts: dict[str, int]) -> dict:
|
|
"""Assemble the selectable pool, annotated with live counts.
|
|
|
|
topic_counts / tag_counts map key -> number of accepted articles, so the
|
|
client can show volume and we can volume-gate the Discovery tags.
|
|
"""
|
|
moods = [
|
|
{"key": m["key"], "label": m["label"], "description": m["description"]}
|
|
for m in MOODS
|
|
if m["key"] != "today"
|
|
]
|
|
topics = [
|
|
{"key": k, "label": _humanize(k), "description": v, "count": int(topic_counts.get(k, 0))}
|
|
for k, v in TOPICS.items()
|
|
]
|
|
discovery = [
|
|
{"key": f"tag:{t}", "label": _humanize(t), "count": int(tag_counts.get(t, 0))}
|
|
for t in ALLOWED_TAGS
|
|
if t not in _TAG_EXCLUDE
|
|
and (tag_counts.get(t, 0) >= _TAG_MIN_COUNT or t in _ALWAYS_OFFER_TAGS)
|
|
]
|
|
discovery.sort(key=lambda d: (-d["count"], d["label"]))
|
|
return {
|
|
"pinned": PINNED,
|
|
"default": DEFAULT_LANES,
|
|
"groups": [
|
|
{"name": "Moods", "lanes": moods},
|
|
{"name": "Topics", "lanes": topics},
|
|
{"name": "Discovery", "lanes": discovery},
|
|
],
|
|
}
|
|
|
|
|
|
def known_lane_keys(pool: dict) -> set[str]:
|
|
"""Every selectable key in a pool (excluding the always-pinned 'today')."""
|
|
return {lane["key"] for group in pool["groups"] for lane in group["lanes"]}
|