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:
@@ -1,14 +1,18 @@
|
|||||||
<script>
|
<script>
|
||||||
// Mobile-only primary navigation. Today = the brief, Browse = mood/topic
|
// Mobile-only primary navigation. Highlights = the brief, Latest = the
|
||||||
// discovery, You = account + personal controls (shows the user's avatar in).
|
// chronological feed, Browse = mood/topic discovery, You = account.
|
||||||
import Avatar from './Avatar.svelte';
|
import Avatar from './Avatar.svelte';
|
||||||
let { active = 'today', onToday, onBrowse, onYou, user = null } = $props();
|
let { active = 'today', onToday, onLatest, onBrowse, onYou, user = null } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<nav class="bottomnav" aria-label="Primary">
|
<nav class="bottomnav" aria-label="Primary">
|
||||||
<button class:active={active === 'today'} onclick={onToday}>
|
<button class:active={active === 'today'} onclick={onToday}>
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4.2" fill="none" stroke="currentColor" stroke-width="1.8" /><path d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6l1.4 1.4M17 17l1.4 1.4M18.4 5.6L17 7M7 17l-1.4 1.4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" /></svg>
|
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4.2" fill="none" stroke="currentColor" stroke-width="1.8" /><path d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6l1.4 1.4M17 17l1.4 1.4M18.4 5.6L17 7M7 17l-1.4 1.4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" /></svg>
|
||||||
<span>Today</span>
|
<span>Highlights</span>
|
||||||
|
</button>
|
||||||
|
<button class:active={active === 'latest'} onclick={onLatest}>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h10" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" /></svg>
|
||||||
|
<span>Latest</span>
|
||||||
</button>
|
</button>
|
||||||
<button class:active={active === 'browse'} onclick={onBrowse}>
|
<button class:active={active === 'browse'} onclick={onBrowse}>
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.8" /><path d="M15.5 8.5l-2 5-5 2 2-5 5-2z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" /></svg>
|
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.8" /><path d="M15.5 8.5l-2 5-5 2 2-5 5-2z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" /></svg>
|
||||||
|
|||||||
@@ -26,10 +26,12 @@
|
|||||||
|
|
||||||
{#snippet body()}
|
{#snippet body()}
|
||||||
<h2>Your lanes</h2>
|
<h2>Your lanes</h2>
|
||||||
<p class="sub">Pick the quick-access lanes above the feed. <strong>Today</strong> always stays — choose the rest. Changes apply right away.</p>
|
<p class="sub"><strong>Highlights</strong> and <strong>Latest</strong> always stay — choose the rest. Changes apply right away.</p>
|
||||||
|
|
||||||
<div class="pinned-row">
|
<div class="pinned-row">
|
||||||
<span class="chip pinned" title="Always shown">Today <span class="lock">📌</span></span>
|
{#each pool?.pinned ?? [] as p (p.key)}
|
||||||
|
<span class="chip pinned" title="Always shown">{p.label} <span class="lock">📌</span></span>
|
||||||
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#each pool?.groups ?? [] as g (g.name)}
|
{#each pool?.groups ?? [] as g (g.name)}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@
|
|||||||
let brief = $state(null);
|
let brief = $state(null);
|
||||||
let heroIdx = $state(0);
|
let heroIdx = $state(0);
|
||||||
let feed = $state([]);
|
let feed = $state([]);
|
||||||
|
let feedDone = $state(false); // no more pages for the current feed view
|
||||||
|
let loadingMore = $state(false);
|
||||||
let showSignIn = $state(false);
|
let showSignIn = $state(false);
|
||||||
let showSaved = $state(false); // Saved flyout
|
let showSaved = $state(false); // Saved flyout
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
@@ -96,24 +98,28 @@
|
|||||||
);
|
);
|
||||||
let viewLabel = $derived(
|
let viewLabel = $derived(
|
||||||
selected === 'today' ? 'Highlights from Today'
|
selected === 'today' ? 'Highlights from Today'
|
||||||
: currentTag ? humanize(currentTag)
|
: selected === 'latest' ? 'Latest'
|
||||||
: (currentMood?.label ?? cap(currentTopic?.key) ?? '')
|
: currentTag ? humanize(currentTag)
|
||||||
|
: (currentMood?.label ?? cap(currentTopic?.key) ?? '')
|
||||||
);
|
);
|
||||||
let viewSubtitle = $derived(
|
let viewSubtitle = $derived(
|
||||||
selected === 'today' ? localDateLabel(brief)
|
selected === 'today' ? localDateLabel(brief)
|
||||||
: currentTag ? (tagFamily?.description ?? '')
|
: selected === 'latest' ? 'Freshest calm reads — newest first'
|
||||||
: (currentMood?.description ?? currentTopic?.description ?? '')
|
: currentTag ? (tagFamily?.description ?? '')
|
||||||
|
: (currentMood?.description ?? currentTopic?.description ?? '')
|
||||||
|
);
|
||||||
|
let activeTab = $derived(
|
||||||
|
selected === 'today' ? 'today' : selected === 'latest' ? 'latest' : 'browse'
|
||||||
);
|
);
|
||||||
let activeTab = $derived(selected === 'today' ? 'today' : 'browse');
|
|
||||||
|
|
||||||
// Customizable nav rail: Today is always first, then the reader's pinned
|
// Customizable nav rail: the pinned lanes (Highlights + Latest) are always
|
||||||
// lanes (or the default set if they've never customized). Resolve each key
|
// first, then the reader's chosen lanes (or the default set if they've never
|
||||||
// to its {label, description} from the pool.
|
// customized). Resolve each key to its {label, description} from the pool.
|
||||||
let laneMap = $derived(
|
let laneMap = $derived(
|
||||||
new Map(
|
new Map(
|
||||||
lanePool
|
lanePool
|
||||||
? [
|
? [
|
||||||
[lanePool.pinned.key, lanePool.pinned],
|
...lanePool.pinned.map((p) => [p.key, p]),
|
||||||
...lanePool.groups.flatMap((g) => g.lanes.map((l) => [l.key, l])),
|
...lanePool.groups.flatMap((g) => g.lanes.map((l) => [l.key, l])),
|
||||||
]
|
]
|
||||||
: []
|
: []
|
||||||
@@ -123,14 +129,15 @@
|
|||||||
prefs.data.lanes?.length ? prefs.data.lanes : (lanePool?.default ?? [])
|
prefs.data.lanes?.length ? prefs.data.lanes : (lanePool?.default ?? [])
|
||||||
);
|
);
|
||||||
let navLanes = $derived(
|
let navLanes = $derived(
|
||||||
lanePool ? [lanePool.pinned, ...pinnedLaneKeys.map((k) => laneMap.get(k)).filter(Boolean)] : []
|
lanePool ? [...lanePool.pinned, ...pinnedLaneKeys.map((k) => laneMap.get(k)).filter(Boolean)] : []
|
||||||
);
|
);
|
||||||
|
|
||||||
function saveLanes(keys) {
|
function saveLanes(keys) {
|
||||||
prefs.data.lanes = keys;
|
prefs.data.lanes = keys;
|
||||||
persistPrefs();
|
persistPrefs();
|
||||||
// If the reader unpinned the lane they're currently viewing, fall back home.
|
// If the reader unpinned the lane they're viewing, fall back to Highlights.
|
||||||
if (selected !== 'today' && !keys.includes(selected)) select('today');
|
// (The pinned Highlights/Latest lanes are never in `keys`, so don't bounce.)
|
||||||
|
if (selected !== 'today' && selected !== 'latest' && !keys.includes(selected)) select('today');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hero is the only image slot; if its image won't load, promote the next one.
|
// Hero is the only image slot; if its image won't load, promote the next one.
|
||||||
@@ -173,22 +180,36 @@
|
|||||||
markDisplayed(brief.items);
|
markDisplayed(brief.items);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build a /api/feed URL for a lane view at a given page offset. 'latest' is
|
||||||
|
// the chronological firehose (sort=latest); 'tag:x' browses a grouping tag;
|
||||||
|
// anything else resolves through its mood/topic filter.
|
||||||
|
const PAGE = 24;
|
||||||
|
function feedUrl(key, offset) {
|
||||||
|
const ex = Array.from(dismissed).join(',');
|
||||||
|
const exq = ex ? `&exclude=${ex}` : '';
|
||||||
|
if (key === 'latest') {
|
||||||
|
const q = P.param(prefs.data);
|
||||||
|
return `/api/feed?sort=latest&limit=${PAGE}&offset=${offset}${q ? '&' + q : ''}${exq}`;
|
||||||
|
}
|
||||||
|
if (key.startsWith('tag:')) {
|
||||||
|
const q = P.param(prefs.data);
|
||||||
|
return `/api/feed?limit=${PAGE}&offset=${offset}&tag=${encodeURIComponent(key.slice(4))}${q ? '&' + q : ''}${exq}`;
|
||||||
|
}
|
||||||
|
const q = P.param(P.merge(prefs.data, viewFilter(key)));
|
||||||
|
return `/api/feed?limit=${PAGE}&offset=${offset}${q ? '&' + q : ''}${exq}`;
|
||||||
|
}
|
||||||
|
|
||||||
async function select(key, fresh = false) {
|
async function select(key, fresh = false) {
|
||||||
selected = key;
|
selected = key;
|
||||||
error = '';
|
error = '';
|
||||||
|
feedDone = false;
|
||||||
try {
|
try {
|
||||||
if (key === 'today') {
|
if (key === 'today') {
|
||||||
await loadToday(fresh);
|
await loadToday(fresh);
|
||||||
} else if (key.startsWith('tag:')) {
|
|
||||||
const tag = key.slice(4);
|
|
||||||
const q = P.param(prefs.data);
|
|
||||||
const ex = Array.from(dismissed).join(',');
|
|
||||||
feed = (await getJSON(`/api/feed?limit=24&tag=${encodeURIComponent(tag)}${q ? '&' + q : ''}${ex ? '&exclude=' + ex : ''}`)).items;
|
|
||||||
markDisplayed(feed);
|
|
||||||
} else {
|
} else {
|
||||||
const q = P.param(P.merge(prefs.data, viewFilter(key)));
|
const items = (await getJSON(feedUrl(key, 0))).items;
|
||||||
const ex = Array.from(dismissed).join(',');
|
feed = items;
|
||||||
feed = (await getJSON(`/api/feed?limit=24${q ? '&' + q : ''}${ex ? '&exclude=' + ex : ''}`)).items;
|
feedDone = items.length < PAGE;
|
||||||
markDisplayed(feed);
|
markDisplayed(feed);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -197,6 +218,25 @@
|
|||||||
if (typeof window !== 'undefined') window.scrollTo({ top: 0, behavior: 'smooth' });
|
if (typeof window !== 'undefined') window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "Load more" for any feed view (Latest, topics, tags, moods): fetch the next
|
||||||
|
// page at the current length and append, de-duping against what's shown.
|
||||||
|
async function loadMore() {
|
||||||
|
if (loadingMore || feedDone || selected === 'today') return;
|
||||||
|
loadingMore = true;
|
||||||
|
try {
|
||||||
|
const items = (await getJSON(feedUrl(selected, feed.length))).items;
|
||||||
|
const have = new Set(feed.map((a) => a.id));
|
||||||
|
const fresh = items.filter((a) => !have.has(a.id));
|
||||||
|
feed = [...feed, ...fresh];
|
||||||
|
feedDone = items.length < PAGE;
|
||||||
|
markDisplayed(fresh);
|
||||||
|
} catch {
|
||||||
|
flash('Could not load more just now.');
|
||||||
|
} finally {
|
||||||
|
loadingMore = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const MIX_EVENT = { notToday: 'not_today', lessLikeThis: 'less_like_this', alwaysHide: 'hide_topic' };
|
const MIX_EVENT = { notToday: 'not_today', lessLikeThis: 'less_like_this', alwaysHide: 'hide_topic' };
|
||||||
function applyAction(kind, value) {
|
function applyAction(kind, value) {
|
||||||
applyPrefAction(kind, value); // updates + persists + syncs to account
|
applyPrefAction(kind, value); // updates + persists + syncs to account
|
||||||
@@ -325,6 +365,15 @@
|
|||||||
<ArticleCard article={a} onaction={applyAction} onreplace={replaceArticle} ontag={(t) => select('tag:' + t)} onview={record} />
|
<ArticleCard article={a} onaction={applyAction} onreplace={replaceArticle} ontag={(t) => select('tag:' + t)} onview={record} />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
{#if !feedDone}
|
||||||
|
<div class="loadmore">
|
||||||
|
<button onclick={loadMore} disabled={loadingMore}>
|
||||||
|
{loadingMore ? 'Loading…' : 'Load more'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="endcap rise">✦ you're all caught up ✦</p>
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<p class="muted center pad">Nothing here right now — try another, or ease a boundary.</p>
|
<p class="muted center pad">Nothing here right now — try another, or ease a boundary.</p>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -354,7 +403,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<BottomNav active={activeTab} onToday={() => select('today')} onBrowse={browse} onYou={openAccount} user={auth.user} />
|
<BottomNav active={activeTab} onToday={() => select('today')} onLatest={() => select('latest')} onBrowse={browse} onYou={openAccount} user={auth.user} />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
main.container { padding-top: 6px; padding-bottom: 40px; min-height: 60vh; }
|
main.container { padding-top: 6px; padding-bottom: 40px; min-height: 60vh; }
|
||||||
@@ -395,4 +444,12 @@
|
|||||||
text-align: center; color: var(--muted); font-family: var(--serif);
|
text-align: center; color: var(--muted); font-family: var(--serif);
|
||||||
font-style: italic; margin: 40px 0 10px; letter-spacing: 0.02em;
|
font-style: italic; margin: 40px 0 10px; letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
.loadmore { display: flex; justify-content: center; margin: 30px 0 6px; }
|
||||||
|
.loadmore button {
|
||||||
|
background: var(--surface); border: 1px solid var(--line); color: var(--accent-deep);
|
||||||
|
border-radius: 999px; padding: 10px 28px; font-size: 0.92rem; cursor: pointer;
|
||||||
|
transition: all 0.14s ease;
|
||||||
|
}
|
||||||
|
.loadmore button:hover { border-color: var(--accent); background: var(--accent-soft); }
|
||||||
|
.loadmore button:disabled { opacity: 0.6; cursor: default; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+3
-2
@@ -921,6 +921,7 @@ def create_app() -> FastAPI:
|
|||||||
prefs: str | None = Query(None),
|
prefs: str | None = Query(None),
|
||||||
exclude: str = Query("", description="comma-separated article ids the reader has dismissed"),
|
exclude: str = Query("", description="comma-separated article ids the reader has dismissed"),
|
||||||
tag: str | None = Query(None, description="grouping tag to browse"),
|
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:
|
) -> FeedResponse:
|
||||||
if topic and topic.lower() not in TOPICS:
|
if topic and topic.lower() not in TOPICS:
|
||||||
raise HTTPException(400, f"unknown topic: {topic}")
|
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))
|
fetch_n = min(2000, (offset + limit) * 4 + 50 + len(excl))
|
||||||
raw = queries.feed(
|
raw = queries.feed(
|
||||||
conn, topic=topic, flavor=flavor, accepted_only=accepted_only,
|
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]
|
kept = [a for a in filter_articles(raw, fp, now) if a["id"] not in excl]
|
||||||
rows = kept[offset : offset + limit]
|
rows = kept[offset : offset + limit]
|
||||||
else:
|
else:
|
||||||
rows = queries.feed(
|
rows = queries.feed(
|
||||||
conn, topic=topic, flavor=flavor, accepted_only=accepted_only,
|
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
|
# Keep the top of a browse view readable: stable-sort paywalled items
|
||||||
# below readable ones (composite order preserved within each group).
|
# below readable ones (composite order preserved within each group).
|
||||||
|
|||||||
+6
-2
@@ -18,8 +18,12 @@ from __future__ import annotations
|
|||||||
from .moods import MOODS
|
from .moods import MOODS
|
||||||
from .taxonomy import ALLOWED_TAGS, TOPICS
|
from .taxonomy import ALLOWED_TAGS, TOPICS
|
||||||
|
|
||||||
# The lane pinned first, always — never user-removable.
|
# The lanes pinned first, always — never user-removable. "Highlights" is the
|
||||||
PINNED = {"key": "today", "label": "Today", "description": "The day's good things."}
|
# 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.
|
# 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"]
|
DEFAULT_LANES: list[str] = [m["key"] for m in MOODS if m["key"] != "today"]
|
||||||
|
|||||||
+12
-2
@@ -57,8 +57,13 @@ def feed(
|
|||||||
max_cortisol: int | None = None,
|
max_cortisol: int | None = None,
|
||||||
max_ragebait: int | None = None,
|
max_ragebait: int | None = None,
|
||||||
tag: str | None = None,
|
tag: str | None = None,
|
||||||
|
sort: str = "ranked",
|
||||||
) -> list[dict]:
|
) -> 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)
|
Categorical filters (topic/flavor include & mute, cortisol/ragebait ceilings)
|
||||||
must be applied here, not after ranking — otherwise low-ranked-but-matching
|
must be applied here, not after ranking — otherwise low-ranked-but-matching
|
||||||
@@ -105,6 +110,11 @@ def feed(
|
|||||||
where = "WHERE " + " AND ".join(clauses)
|
where = "WHERE " + " AND ".join(clauses)
|
||||||
params.extend([limit, offset])
|
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(
|
rows = conn.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT {_ARTICLE_COLUMNS}
|
SELECT {_ARTICLE_COLUMNS}
|
||||||
@@ -112,7 +122,7 @@ def feed(
|
|||||||
JOIN sources src ON src.id = a.source_id
|
JOIN sources src ON src.id = a.source_id
|
||||||
JOIN article_scores s ON s.article_id = a.id
|
JOIN article_scores s ON s.article_id = a.id
|
||||||
{where}
|
{where}
|
||||||
ORDER BY rank_score DESC, COALESCE(a.published_at, a.discovered_at) DESC
|
ORDER BY {order_by}
|
||||||
LIMIT ? OFFSET ?
|
LIMIT ? OFFSET ?
|
||||||
""",
|
""",
|
||||||
params,
|
params,
|
||||||
|
|||||||
@@ -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
@@ -3,12 +3,13 @@ from goodnews.lanes import build_lane_pool, known_lane_keys, DEFAULT_LANES
|
|||||||
|
|
||||||
def test_pool_shape_and_pinned():
|
def test_pool_shape_and_pinned():
|
||||||
pool = build_lane_pool({"science": 100}, {"space": 200, "food": 0, "innovation": 99})
|
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
|
assert pool["default"] == DEFAULT_LANES
|
||||||
names = [g["name"] for g in pool["groups"]]
|
names = [g["name"] for g in pool["groups"]]
|
||||||
assert names == ["Moods", "Topics", "Discovery"]
|
assert names == ["Moods", "Topics", "Discovery"]
|
||||||
# 'today' is pinned, never part of the selectable pool.
|
# pinned lanes are never part of the selectable pool.
|
||||||
assert "today" not in known_lane_keys(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():
|
def test_topics_are_bare_keys_tags_are_prefixed():
|
||||||
|
|||||||
Reference in New Issue
Block a user