openvibers/openvibe.news Commits

Menu: today's top stories, else the latest stories (#235)

OP OpenVibers committed 6 days ago · 0581a6e36b · 3 files, +111 −29

Changes

ddiff --git a/server/app.js b/server/app.js
iindex b33c46f..716e404 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -210,12 +210,13 @@ async function createApp(opts = {}) {
},
}));
-- // The menu's network card (frame.menu-cards@1): the newest published stories, read anonymously. It is the
-- // same public listing the home page uses, so a draft or a retracted story never reaches the card.
++ // The menu's network card (frame.menu-cards@1): today's published stories, else the newest of any age, read
++ // anonymously. It is the same public listing the home page uses, so a draft, private or retracted story never
++ // reaches the card. Twenty rows leave room for retracted ones while the card still fills its five.
{
const menuCard = createMenuCard({
load: async () => {
-- const { stories: rows } = await stories.listPublished({ limit: 6 });
++ const { stories: rows } = await stories.listPublished({ limit: 20 });
return await reading.listItems(rows);
},
});
ddiff --git a/server/menu-card.js b/server/menu-card.js
iindex e939c0f..dc01f55 100644
---- a/server/menu-card.js
++++ b/server/menu-card.js
@@@ -1,17 +1,20 @@
'use strict';
// The menu's network card for OpenVibe.News (frame.menu-card@1): GET /.well-known/openvibe-menu.json.
//
--// News publishes "Top stories": the newest published stories readers can open, each linking to its story page.
--// The rows come from the same public listing the home page uses (stories.listPublished + reading.listItems), so
--// the card can only show what an anonymous reader can already read: drafts and retracted stories never appear.
++// News publishes "Top stories": the published stories first published today (UTC), newest first, each linking to
++// its story page. When nothing was published today the card falls back to the newest published stories of any
++// age and says so: "Latest stories". The rows come from the same public listing the home page uses
++// (stories.listPublished + reading.listItems), so the card can only show what an anonymous reader can already
++// read: drafts, unpublished and private stories, and retracted stories never appear.
// The file is public and CORS *, cached for five minutes (the card's ttl); Network fetches it every five minutes.
--// With nothing published the card is still served, with no items, and Network skips it.
++// With nothing published at all the card is still served, with no items, and Network skips it.
const BASE = 'https://openvibe.news';
const CARD_ID = 'top-stories';
const SITE = 'news';
const CARD_TTL_S = 300;
const CACHE_MS = CARD_TTL_S * 1000;
--const LIMIT = 6;
++const LIMIT = 5;
++const DAY = 24 * 60 * 60_000;
/** Plain words for a card: no markup, no control characters, one line, within the schema's limit. */
function plain(value, max) {
@@@ -23,10 +26,21 @@ function plain(value, max) {
.slice(0, max);
}
++/** A publication instant in epoch ms: first_published_at is epoch milliseconds (int8 → Number), ISO works too. */
++function instantOf(value) {
++ const at = typeof value === 'number' ? value : Date.parse(String(value || ''));
++ return Number.isFinite(at) ? at : NaN;
++}
++
++/** The UTC day a build time falls in starts here. "Today" is UTC, as on the rest of the network. */
++function startOfDay(value) {
++ const at = instantOf(value);
++ return Number.isFinite(at) ? Date.parse(`${new Date(at).toISOString().slice(0, 10)}T00:00:00.000Z`) : NaN;
++}
++
/** "just now", "12m ago", "3h ago" or "2d ago" for a story's first publication; empty when unknown. */
function ago(value, now) {
-- // first_published_at is epoch milliseconds (a bigint from the database), but a date string works too.
-- const at = typeof value === 'number' ? value : Date.parse(String(value || ''));
++ const at = instantOf(value);
if (!Number.isFinite(at)) return '';
const mins = Math.max(0, Math.floor((now - at) / 60_000));
if (mins < 1) return 'just now';
@@@ -36,22 +50,38 @@ function ago(value, now) {
return `${Math.floor(hours / 24)}d ago`;
}
--/** The card itself. `items` are reading.listItems() rows: { headline, url (path), outlets, published_at, retracted }. */
++/**
++ * The card itself. `items` are reading.listItems() rows: { headline, url (path), outlets, published_at,
++ * retracted }. The newest published story wins; a story first published today (UTC) is a top story, and
++ * when there is none the card is the latest stories of any age — the title says which it is.
++ */
function cardFrom(items, at = Date.now()) {
-- const out = [];
++ const since = startOfDay(at);
++ const valid = [];
for (const it of Array.isArray(items) ? items : []) {
if (!it || it.retracted) continue;
const title = plain(it.headline, 80);
const path = String(it.url || '');
if (!title || !path.startsWith('/')) continue;
-- const meta = [plain((it.outlets || [])[0], 60), ago(it.published_at, at)].filter(Boolean).join(' · ');
-- out.push({ title, ...(meta ? { meta: plain(meta, 120) } : {}), url: `${BASE}${path}` });
-- if (out.length >= LIMIT) break;
++ const published = instantOf(it.published_at);
++ valid.push({
++ it, title, path, published,
++ top: Number.isFinite(since) && Number.isFinite(published) && published >= since && published < since + DAY,
++ });
}
++ // The public listing is newest-first already; sort here too so the fallback is the newest by first publication.
++ valid.sort((a, b) => ((Number.isFinite(b.published) ? b.published : 0) - (Number.isFinite(a.published) ? a.published : 0)) || (a.path < b.path ? -1 : 1));
++ const top = valid.filter((v) => v.top);
++ const chosen = (top.length ? top : valid).slice(0, LIMIT);
++ const out = chosen.map(({ it, title, path }) => {
++ const outlets = Array.isArray(it.outlets) ? it.outlets : [];
++ const meta = [plain(outlets[0], 60), ago(it.published_at, at)].filter(Boolean).join(' · ');
++ return { title, ...(meta ? { meta: plain(meta, 120) } : {}), url: `${BASE}${path}` };
++ });
return {
id: CARD_ID,
site: SITE,
-- title: 'Top stories',
++ title: top.length ? 'Top stories' : 'Latest stories',
kind: 'rows',
items: out,
more: { label: 'All stories', url: `${BASE}/` },
@@@ -60,7 +90,7 @@ function cardFrom(items, at = Date.now()) {
};
}
--/** The route. `load` returns reading.listItems() rows; `now` is injectable for the test. */
++/** The route. `load(at)` returns reading.listItems() rows; `now` is injectable for the test. */
function createMenuCard({ load, now = Date.now } = {}) {
let cached = null;
let cachedAt = 0;
@@@ -72,7 +102,7 @@ function createMenuCard({ load, now = Date.now } = {}) {
if (pending) return pending;
pending = (async () => {
try {
-- cached = cardFrom(await load(), at);
++ cached = cardFrom(await load(at), at);
cachedAt = at;
} catch {
// Keep the last good card rather than publish an empty one on a database hiccup.
@@@ -94,4 +124,4 @@ function createMenuCard({ load, now = Date.now } = {}) {
return { card, handler, cardFrom, cacheMs: CACHE_MS };
}
--module.exports = { createMenuCard, cardFrom, ago, plain, BASE, CARD_ID, SITE, CARD_TTL_S, LIMIT };
++module.exports = { createMenuCard, cardFrom, startOfDay, ago, plain, BASE, CARD_ID, SITE, CARD_TTL_S, DAY, LIMIT };
ddiff --git a/test/menu-card.test.js b/test/menu-card.test.js
iindex 9b382d2..ffd1715 100644
---- a/test/menu-card.test.js
++++ b/test/menu-card.test.js
@@@ -1,10 +1,15 @@
'use strict';
--// The menu's network card for OpenVibe.News (frame.menu-card@1): GET /.well-known/openvibe-menu.json serves the
--// "Top stories" array — the newest published stories, each linking to its page on openvibe.news. It validates
--// against frame.menu-cards@1, never carries a retracted story, is CORS * and is cached for five minutes.
++// The menu's network card for OpenVibe.News (frame.menu-card@1): GET /.well-known/openvibe-menu.json serves
++// "Top stories" — the published stories first published today (UTC) — and "Latest stories", the newest published
++// stories of any age, when nothing was published today. Each links to its page on openvibe.news. It validates
++// against frame.menu-cards@1, never carries a retracted, draft, unpublished or private story, is CORS * and is
++// cached for five minutes. The public listing is exercised on a real (PGlite) database too.
const assert = require('assert');
const contracts = require('openvibe-contracts');
--const { createMenuCard, cardFrom, ago, plain, CARD_TTL_S } = require('../server/menu-card');
++const { createMenuCard, cardFrom, startOfDay, ago, plain, CARD_TTL_S } = require('../server/menu-card');
++const { testDb } = require('./helpers/db');
++const { createStore } = require('../server/db');
++const { createStories } = require('../server/domain/stories');
const NOW = Date.parse('2026-11-07T12:00:00Z');
const item = (over = {}) => ({
@@@ -12,10 +17,11 @@ const item = (over = {}) => ({
published_at: NOW - 2 * 60 * 60_000, retracted: false, ...over,
});
--// ── The card's shape and words ───────────────────────────────────────────
++// ── Today's card: shape, words and contract ─────────────────────────────
const card = cardFrom([
item(),
-- item({ headline: '<b>Second</b>\nstory', url: '/stories/second', outlets: ['Other', 'Third'], published_at: NOW - 26 * 60 * 60_000 }),
++ item({ headline: '<b>Second</b>\nstory', url: '/stories/second', outlets: ['Other', 'Third'], published_at: NOW - 5 * 60 * 60_000 }),
++ item({ headline: 'Yesterday', url: '/stories/yesterday', outlets: ['Old Wire'], published_at: NOW - 26 * 60 * 60_000 }),
], NOW);
const checked = contracts.validate('frame.menu-cards@1', [card]);
assert.ok(checked.valid, JSON.stringify(checked.errors));
@@@ -28,19 +34,32 @@ assert.strictEqual(card.ttl_s, CARD_TTL_S);
assert.strictEqual(card.updated_at, new Date(NOW).toISOString());
assert.deepStrictEqual(card.items, [
{ title: 'A story readers can check', meta: 'Example News · 2h ago', url: 'https://openvibe.news/stories/a-story' },
-- { title: 'Second story', meta: 'Other · 1d ago', url: 'https://openvibe.news/stories/second' },
++ { title: 'Second story', meta: 'Other · 5h ago', url: 'https://openvibe.news/stories/second' },
]);
assert.deepStrictEqual(card.more, { label: 'All stories', url: 'https://openvibe.news/' });
assert.ok(card.title.length <= 60);
for (const it of card.items) assert.ok(it.title.length <= 80 && (!it.meta || it.meta.length <= 120) && it.url.startsWith('https://openvibe.news/'));
--// Retracted and incomplete stories never enter the card, and at most six stories are sent.
++// Nothing today: the card falls back to the newest published stories of any age and says "Latest stories".
++const latest = cardFrom([
++ item({ headline: 'Yesterday', url: '/stories/yesterday', published_at: NOW - 26 * 60 * 60_000 }),
++ item({ headline: 'Last week', url: '/stories/last-week', published_at: NOW - 8 * 24 * 60 * 60_000 }),
++], NOW);
++assert.strictEqual(latest.title, 'Latest stories');
++assert.deepStrictEqual(latest.items.map((i) => i.title), ['Yesterday', 'Last week']);
++assert.ok(contracts.validate('frame.menu-cards@1', [latest]).valid);
++const empty = cardFrom([], NOW);
++assert.deepStrictEqual(empty.items, []);
++assert.strictEqual(empty.title, 'Latest stories');
++assert.ok(contracts.validate('frame.menu-cards@1', [empty]).valid);
++
++// Retracted and incomplete stories never enter the card, and at most five stories are sent.
assert.deepStrictEqual(cardFrom([item({ retracted: true })], NOW).items, []);
assert.deepStrictEqual(cardFrom([item({ headline: ' ' })], NOW).items, []);
assert.deepStrictEqual(cardFrom([item({ url: 'https://elsewhere.example/x' })], NOW).items, []);
assert.deepStrictEqual(cardFrom(null, NOW).items, []);
const many = Array.from({ length: 9 }, (_, i) => item({ headline: `Story ${i}`, url: `/stories/${i}` }));
--assert.strictEqual(cardFrom(many, NOW).items.length, 6);
++assert.strictEqual(cardFrom(many, NOW).items.length, 5);
// Text is plain words and the meta line names the outlet and the time ago.
assert.strictEqual(plain('<img src=x onerror=alert(1)>Nova\n\t live ', 80), 'Nova live');
@@@ -48,6 +67,7 @@ assert.strictEqual(plain('x'.repeat(200), 80).length, 80);
assert.strictEqual(ago(NOW - 30_000, NOW), 'just now');
assert.strictEqual(ago(NOW - 3 * 60 * 60_000, NOW), '3h ago');
assert.strictEqual(ago(null, NOW), '');
++assert.strictEqual(startOfDay(NOW), Date.parse('2026-11-07T00:00:00Z'));
// ── The five-minute cache and the route's headers ───────────────────────
(async () => {
@@@ -83,5 +103,36 @@ assert.strictEqual(ago(null, NOW), '');
t += CARD_TTL_S * 1000 + 1;
assert.strictEqual(await flaky.card(), good);
-- console.log('menu card: shape, contract, retracted filter, plain text, 5 min cache, CORS and last-good card');
++ // ── The public listing: published, public rows only, newest first ──
++ const handle = await testDb();
++ const db = handle.db;
++ try {
++ const store = createStore(db, { now: () => NOW });
++ const stories = createStories({ store, config: { baseUrl: 'https://openvibe.news' }, publication: {}, clusters: {}, outbox: {} });
++ const insert = db.prepare(`INSERT INTO news_stories
++ (id, slug, working_headline, state, published_revision, first_published_at, published_at, created_by, created_at, updated_at, audience)
++ VALUES (?, ?, ?, ?, ?, ?, ?, 'usr_editor', ?, ?, ?)`);
++ const row = (id, state, first, audience = 'public', revision = 1) =>
++ insert.run(id, `story-${id}`, `Headline ${id}`, state, revision, first, first, NOW, NOW, audience);
++ await row('sty_today', 'published', NOW - 60 * 60_000);
++ await row('sty_retracted', 'retracted', NOW - 3 * 60 * 60_000);
++ await row('sty_old', 'published', NOW - 26 * 60 * 60_000);
++ await row('sty_draft', 'draft', null, 'private', null);
++ await row('sty_unpublished', 'unpublished', null, 'public', null);
++ await row('sty_private', 'published', NOW - 2 * 60 * 60_000, 'private');
++ const { stories: rows } = await stories.listPublished({ limit: 20 });
++ assert.deepStrictEqual(rows.map((r) => r.id), ['sty_today', 'sty_retracted', 'sty_old'],
++ 'drafts, unpublished and private stories never reach the public listing; retracted ones stay for the card to drop');
++ const asItem = (r) => ({ headline: r.working_headline, url: `/stories/${r.slug}`, outlets: ['Wire'], published_at: r.first_published_at, retracted: r.state === 'retracted' });
++ const served = cardFrom(rows.map(asItem), NOW);
++ assert.strictEqual(served.title, 'Top stories');
++ assert.deepStrictEqual(served.items.map((i) => i.title), ['Headline sty_today']);
++ const fallback = cardFrom(rows.filter((r) => r.first_published_at < startOfDay(NOW)).map(asItem), NOW);
++ assert.strictEqual(fallback.title, 'Latest stories');
++ assert.deepStrictEqual(fallback.items.map((i) => i.title), ['Headline sty_old']);
++ } finally {
++ await handle.close();
++ }
++
++ console.log('menu card: top/latest, contract, public listing, retracted filter, plain text, 5 min cache, CORS and last-good card');
})().catch((err) => { console.error(err); process.exit(1); });