openvibers/openvibe.news Commits
Menu: today's top stories, else the latest stories (#235)
Changes
@@@ -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);},});@@@ -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 };@@@ -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); });
@@ -210,12 +210,13 @@ async function createApp(opts = {}) {
@@ -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({
const menuCard = createMenuCard({
load: async () => {
load: async () => {
- const { stories: rows } = await stories.listPublished({ limit: 6 });
+ const { stories: rows } = await stories.listPublished({ limit: 20 });
return await reading.listItems(rows);
return await reading.listItems(rows);
},
},
});
});
@@ -1,17 +1,20 @@
@@ -1,17 +1,20 @@
'use strict';
'use strict';
// The menu's network card for OpenVibe.News (frame.menu-card@1): GET /.well-known/openvibe-menu.json.
// 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.
// 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 BASE = 'https://openvibe.news';
const CARD_ID = 'top-stories';
const CARD_ID = 'top-stories';
const SITE = 'news';
const SITE = 'news';
const CARD_TTL_S = 300;
const CARD_TTL_S = 300;
const CACHE_MS = CARD_TTL_S * 1000;
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. */
/** Plain words for a card: no markup, no control characters, one line, within the schema's limit. */
function plain(value, max) {
function plain(value, max) {
@@ -23,10 +26,21 @@ function plain(value, max) {
@@ -23,10 +26,21 @@ function plain(value, max) {
.slice(0, 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. */
/** "just now", "12m ago", "3h ago" or "2d ago" for a story's first publication; empty when unknown. */
function ago(value, now) {
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 '';
if (!Number.isFinite(at)) return '';
const mins = Math.max(0, Math.floor((now - at) / 60_000));
const mins = Math.max(0, Math.floor((now - at) / 60_000));
if (mins < 1) return 'just now';
if (mins < 1) return 'just now';
@@ -36,22 +50,38 @@ function ago(value, now) {
@@ -36,22 +50,38 @@ function ago(value, now) {
return `${Math.floor(hours / 24)}d ago`;
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()) {
function cardFrom(items, at = Date.now()) {
- const out = [];
+ const since = startOfDay(at);
+ const valid = [];
for (const it of Array.isArray(items) ? items : []) {
for (const it of Array.isArray(items) ? items : []) {
if (!it || it.retracted) continue;
if (!it || it.retracted) continue;
const title = plain(it.headline, 80);
const title = plain(it.headline, 80);
const path = String(it.url || '');
const path = String(it.url || '');
if (!title || !path.startsWith('/')) continue;
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 {
return {
id: CARD_ID,
id: CARD_ID,
site: SITE,
site: SITE,
- title: 'Top stories',
+ title: top.length ? 'Top stories' : 'Latest stories',
kind: 'rows',
kind: 'rows',
items: out,
items: out,
more: { label: 'All stories', url: `${BASE}/` },
more: { label: 'All stories', url: `${BASE}/` },
@@ -60,7 +90,7 @@ function cardFrom(items, at = Date.now()) {
@@ -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 } = {}) {
function createMenuCard({ load, now = Date.now } = {}) {
let cached = null;
let cached = null;
let cachedAt = 0;
let cachedAt = 0;
@@ -72,7 +102,7 @@ function createMenuCard({ load, now = Date.now } = {}) {
@@ -72,7 +102,7 @@ function createMenuCard({ load, now = Date.now } = {}) {
if (pending) return pending;
if (pending) return pending;
pending = (async () => {
pending = (async () => {
try {
try {
- cached = cardFrom(await load(), at);
+ cached = cardFrom(await load(at), at);
cachedAt = at;
cachedAt = at;
} catch {
} catch {
// Keep the last good card rather than publish an empty one on a database hiccup.
// 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 } = {}) {
@@ -94,4 +124,4 @@ function createMenuCard({ load, now = Date.now } = {}) {
return { card, handler, cardFrom, cacheMs: CACHE_MS };
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 };
@@ -1,10 +1,15 @@
@@ -1,10 +1,15 @@
'use strict';
'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 assert = require('assert');
const contracts = require('openvibe-contracts');
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 NOW = Date.parse('2026-11-07T12:00:00Z');
const item = (over = {}) => ({
const item = (over = {}) => ({
@@ -12,10 +17,11 @@ const item = (over = {}) => ({
@@ -12,10 +17,11 @@ const item = (over = {}) => ({
published_at: NOW - 2 * 60 * 60_000, retracted: false, ...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([
const card = cardFrom([
item(),
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);
], NOW);
const checked = contracts.validate('frame.menu-cards@1', [card]);
const checked = contracts.validate('frame.menu-cards@1', [card]);
assert.ok(checked.valid, JSON.stringify(checked.errors));
assert.ok(checked.valid, JSON.stringify(checked.errors));
@@ -28,19 +34,32 @@ assert.strictEqual(card.ttl_s, CARD_TTL_S);
@@ -28,19 +34,32 @@ assert.strictEqual(card.ttl_s, CARD_TTL_S);
assert.strictEqual(card.updated_at, new Date(NOW).toISOString());
assert.strictEqual(card.updated_at, new Date(NOW).toISOString());
assert.deepStrictEqual(card.items, [
assert.deepStrictEqual(card.items, [
{ title: 'A story readers can check', meta: 'Example News · 2h ago', url: 'https://openvibe.news/stories/a-story' },
{ 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.deepStrictEqual(card.more, { label: 'All stories', url: 'https://openvibe.news/' });
assert.ok(card.title.length <= 60);
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/'));
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({ retracted: true })], NOW).items, []);
assert.deepStrictEqual(cardFrom([item({ headline: ' ' })], NOW).items, []);
assert.deepStrictEqual(cardFrom([item({ headline: ' ' })], NOW).items, []);
assert.deepStrictEqual(cardFrom([item({ url: 'https://elsewhere.example/x' })], NOW).items, []);
assert.deepStrictEqual(cardFrom([item({ url: 'https://elsewhere.example/x' })], NOW).items, []);
assert.deepStrictEqual(cardFrom(null, NOW).items, []);
assert.deepStrictEqual(cardFrom(null, NOW).items, []);
const many = Array.from({ length: 9 }, (_, i) => item({ headline: `Story ${i}`, url: `/stories/${i}` }));
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.
// 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');
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);
@@ -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 - 30_000, NOW), 'just now');
assert.strictEqual(ago(NOW - 3 * 60 * 60_000, NOW), '3h ago');
assert.strictEqual(ago(NOW - 3 * 60 * 60_000, NOW), '3h ago');
assert.strictEqual(ago(null, NOW), '');
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 ───────────────────────
// ── The five-minute cache and the route's headers ───────────────────────
(async () => {
(async () => {
@@ -83,5 +103,36 @@ assert.strictEqual(ago(null, NOW), '');
@@ -83,5 +103,36 @@ assert.strictEqual(ago(null, NOW), '');
t += CARD_TTL_S * 1000 + 1;
t += CARD_TTL_S * 1000 + 1;
assert.strictEqual(await flaky.card(), good);
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); });
})().catch((err) => { console.error(err); process.exit(1); });