From 722bcf63173235afcb7fbeb28e3484711b09d274 Mon Sep 17 00:00:00 2001 From: jay Date: Sat, 6 Jun 2026 18:19:58 +0000 Subject: [PATCH] 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) --- .gitignore | 1 + frontend/src/lib/components/LanePicker.svelte | 108 ++++++++++++++++++ frontend/src/lib/components/MoodNav.svelte | 17 ++- frontend/src/lib/prefs.js | 4 + frontend/src/routes/+page.svelte | 40 ++++++- goodnews/api.py | 13 +++ goodnews/lanes.py | 75 ++++++++++++ goodnews/taxonomy.py | 2 +- tests/test_lanes.py | 35 ++++++ 9 files changed, 289 insertions(+), 6 deletions(-) create mode 100644 frontend/src/lib/components/LanePicker.svelte create mode 100644 goodnews/lanes.py create mode 100644 tests/test_lanes.py diff --git a/.gitignore b/.gitignore index 4052efb..18a10cf 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ __pycache__/ data/*.sqlite3 data/*.sqlite3-* +logs/ diff --git a/frontend/src/lib/components/LanePicker.svelte b/frontend/src/lib/components/LanePicker.svelte new file mode 100644 index 0000000..914b20a --- /dev/null +++ b/frontend/src/lib/components/LanePicker.svelte @@ -0,0 +1,108 @@ + + + + + + + diff --git a/frontend/src/lib/components/MoodNav.svelte b/frontend/src/lib/components/MoodNav.svelte index 056cf41..ee7e3ca 100644 --- a/frontend/src/lib/components/MoodNav.svelte +++ b/frontend/src/lib/components/MoodNav.svelte @@ -1,13 +1,18 @@ diff --git a/frontend/src/lib/prefs.js b/frontend/src/lib/prefs.js index 6f6a039..930a75c 100644 --- a/frontend/src/lib/prefs.js +++ b/frontend/src/lib/prefs.js @@ -7,6 +7,10 @@ export function blank() { include_topics: [], include_flavors: [], mute_topics: [], mute_flavors: [], avoid_terms: [], pauses: [], max_cortisol: null, + // UI-only: which nav lanes the reader has pinned (keys from /api/lanes). + // [] means "not customized" — the feed falls back to the default lane set. + // The backend filter parser ignores unknown keys, so this rides along safely. + lanes: [], }; } diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 55339f3..db7ab32 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -6,17 +6,20 @@ import Header from '$lib/components/Header.svelte'; import BottomNav from '$lib/components/BottomNav.svelte'; import MoodNav from '$lib/components/MoodNav.svelte'; + import LanePicker from '$lib/components/LanePicker.svelte'; import ArticleCard from '$lib/components/ArticleCard.svelte'; import SignIn from '$lib/components/SignIn.svelte'; import SavedFlyout from '$lib/components/SavedFlyout.svelte'; import { auth, refresh as refreshAuth } from '$lib/auth.svelte.js'; - import { prefs, initPrefs, active as prefsActive, applyPrefAction, syncPrefsOnLogin } from '$lib/prefs.svelte.js'; + import { prefs, initPrefs, active as prefsActive, applyPrefAction, persistPrefs, syncPrefsOnLogin } from '$lib/prefs.svelte.js'; import { initHistory, deviceIds, record, loadServerHistory } from '$lib/history.svelte.js'; import { trackVisit, track } from '$lib/analytics.js'; let moods = $state([]); let topics = $state([]); let families = $state([]); + let lanePool = $state(null); // /api/lanes: { pinned, default, groups } + let showLanes = $state(false); let selected = $state('today'); // 'today' | a mood key | a topic key | 'tag:' let brief = $state(null); let heroIdx = $state(0); @@ -92,6 +95,33 @@ ); let activeTab = $derived(selected === 'today' ? 'today' : '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. + let laneMap = $derived( + new Map( + lanePool + ? [ + [lanePool.pinned.key, lanePool.pinned], + ...lanePool.groups.flatMap((g) => g.lanes.map((l) => [l.key, l])), + ] + : [] + ) + ); + let pinnedLaneKeys = $derived( + prefs.data.lanes?.length ? prefs.data.lanes : (lanePool?.default ?? []) + ); + let navLanes = $derived( + 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'); + } + // Hero is the only image slot; if its image won't load, promote the next one. let heroArticle = $derived(brief?.items?.[heroIdx] ?? null); let restArticles = $derived((brief?.items ?? []).filter((_, i) => i !== heroIdx)); @@ -226,6 +256,7 @@ try { moods = await getJSON('/api/moods'); topics = (await getJSON('/api/categories')).topics; + try { lanePool = await getJSON('/api/lanes'); } catch { lanePool = null; } try { families = await getJSON('/api/families'); } catch { families = []; } await select('today'); } catch (e) { @@ -239,10 +270,13 @@ {#if showSignIn} (showSignIn = false)} />{/if} {#if showSaved && auth.user} (showSaved = false)} />{/if} +{#if showLanes && lanePool} + (showLanes = false)} /> +{/if}
- {#if moods.length} - + {#if navLanes.length} + (showLanes = true)} /> {/if} {#if notice}

{notice}

{/if} diff --git a/goodnews/api.py b/goodnews/api.py index 01e0bcb..9b72ca1 100644 --- a/goodnews/api.py +++ b/goodnews/api.py @@ -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. diff --git a/goodnews/lanes.py b/goodnews/lanes.py new file mode 100644 index 0000000..5f2d317 --- /dev/null +++ b/goodnews/lanes.py @@ -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:". + +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"]} diff --git a/goodnews/taxonomy.py b/goodnews/taxonomy.py index d92d90f..78fe7a1 100644 --- a/goodnews/taxonomy.py +++ b/goodnews/taxonomy.py @@ -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"], }, } diff --git a/tests/test_lanes.py b/tests/test_lanes.py new file mode 100644 index 0000000..c88a177 --- /dev/null +++ b/tests/test_lanes.py @@ -0,0 +1,35 @@ +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" + 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) + + +def test_topics_are_bare_keys_tags_are_prefixed(): + pool = build_lane_pool({"science": 5}, {}) + topics = next(g for g in pool["groups"] if g["name"] == "Topics")["lanes"] + assert any(l["key"] == "science" for l in topics) # bare topic key + disc = next(g for g in pool["groups"] if g["name"] == "Discovery")["lanes"] + assert all(l["key"].startswith("tag:") for l in disc) + + +def test_volume_gate_and_always_offer(): + # space/food are always offered; a low-volume non-curated tag is dropped. + pool = build_lane_pool({}, {"space": 1, "food": 0, "resilience": 3, "innovation": 999}) + disc_keys = {l["key"] for l in next(g for g in pool["groups"] if g["name"] == "Discovery")["lanes"]} + assert "tag:space" in disc_keys and "tag:food" in disc_keys + assert "tag:innovation" in disc_keys # over threshold + assert "tag:resilience" not in disc_keys # under threshold, not curated + + +def test_topic_named_tags_excluded_from_discovery(): + # a tag that duplicates a primary topic must not appear as a Discovery lane + pool = build_lane_pool({}, {"science": 999, "technology": 999}) + disc_keys = {l["key"] for l in next(g for g in pool["groups"] if g["name"] == "Discovery")["lanes"]} + assert "tag:science" not in disc_keys and "tag:technology" not in disc_keys