Customizable nav lanes: pin moods / topics / discovery tags

Readers can now choose which quick-access lanes sit above the feed; "Today"
stays pinned. The pool (goodnews/lanes.py, served at /api/lanes) is one source
of truth over three lane kinds the feed already renders: moods, primary topics,
and high-volume Discovery tags. Selection lives in the existing prefs blob
(localStorage + /api/prefs sync); the filter parser ignores the new `lanes`
field, so it rides along harmlessly. Default = today's moods, unchanged.

Food/Space stay grouping tags rather than primary topics (per review): `space`
already existed; added `food` to the Mind & Craft family so the classifier
assigns it, and seeded the Food lane by re-tagging the two food sources.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-06 18:19:58 +00:00
parent 8653a46fd4
commit 722bcf6317
9 changed files with 289 additions and 6 deletions
+13
View File
@@ -37,6 +37,7 @@ from .filters import filter_articles, prefs_from_json
from .hero import safe_to_lead
from .llm import LocalModelClient
from .moods import MOODS, mood_filter
from .lanes import build_lane_pool
from .paywall import is_paywalled
from .taxonomy import FAMILIES, FLAVORS, TOPICS
@@ -866,6 +867,18 @@ def create_app() -> FastAPI:
# client merges with the user's own Calm Filters.
return MOODS
@app.get("/api/lanes")
def lanes() -> dict:
# The customizable quick-access rail: 'today' is always pinned, and the
# reader pins any subset of these moods / topics / Discovery tags. Live
# counts let the client gate empty lanes and show volume.
with get_conn() as conn:
tagc = queries.tag_counts(conn)
topicc: dict[str, int] = {}
for row in queries.category_counts(conn):
topicc[row["topic"]] = topicc.get(row["topic"], 0) + int(row["count"])
return build_lane_pool(topicc, tagc)
@app.get("/api/families")
def families() -> list[dict]:
# Grouping vocabulary organised into calm families for the Explore UI.
+75
View File
@@ -0,0 +1,75 @@
"""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 lane pinned first, always — never user-removable.
PINNED = {"key": "today", "label": "Today", "description": "The day's good things."}
# 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"]}
+1 -1
View File
@@ -40,7 +40,7 @@ FAMILIES: dict[str, dict] = {
},
"Mind & Craft": {
"description": "Ideas, learning, and making.",
"tags": ["learning", "ideas", "arts", "books", "creativity", "perspective", "work-life"],
"tags": ["learning", "ideas", "arts", "books", "creativity", "perspective", "work-life", "food"],
},
}