openvibers/openvibe.news Commits

With no published story, News's menu card is the latest AI briefs (#236)

OP OpenVibers committed 6 days ago · bf9d84c28e · 3 files, +57 −7

OpenVibe.News has no published story yet, so its menu card stayed empty and Network left it out, while the home shows
125 source-linked AI briefs. The card now falls back to the newest of them (summaries with text, no stubs), each row
saying it is an AI brief and how many reports it rests on, linking to its /briefs page, titled 'Latest AI briefs'. A
published story, when there is one, still wins.

Changes

ddiff --git a/server/app.js b/server/app.js
iindex 716e404..0a5bdfe 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -219,6 +219,8 @@ async function createApp(opts = {}) {
const { stories: rows } = await stories.listPublished({ limit: 20 });
return await reading.listItems(rows);
},
++ // No published story yet: the newest AI briefs, as the home shows them (11-07).
++ loadBriefs: async () => (briefs ? await briefs.latest(12) : []),
});
app.get('/.well-known/openvibe-menu.json', menuCard.handler);
}
ddiff --git a/server/menu-card.js b/server/menu-card.js
iindex dc01f55..2c5ff97 100644
---- a/server/menu-card.js
++++ b/server/menu-card.js
@@@ -7,7 +7,9 @@
// (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 at all the card is still served, with no items, and Network skips it.
++// With no published story at all (11-07: none yet), the card is "Latest AI briefs": OpenVibe.AI's source-linked briefs,
++// each row saying it is one and how many reports it rests on, the same briefs the home shows. Only with neither is the
++// card served empty, and Network skips it.
const BASE = 'https://openvibe.news';
const CARD_ID = 'top-stories';
const SITE = 'news';
@@@ -90,8 +92,39 @@ function cardFrom(items, at = Date.now()) {
};
}
--/** The route. `load(at)` returns reading.listItems() rows; `now` is injectable for the test. */
--function createMenuCard({ load, now = Date.now } = {}) {
++/**
++ * The card when no story is published: the newest AI briefs (briefs.latest(), summaries only, no stub, with text),
++ * each linking to its /briefs/:cluster_id page, its meta saying it is an AI brief and how many reports it rests on.
++ */
++function cardFromBriefs(briefs, at = Date.now()) {
++ const out = [];
++ for (const b of Array.isArray(briefs) ? briefs : []) {
++ if (!b || b.kind !== 'summary' || b.stub || !Array.isArray(b.paragraphs) || !b.paragraphs.length) continue;
++ const title = plain(b.headline || b.label, 80);
++ const id = String(b.cluster_id || '');
++ if (!title || !/^[A-Za-z0-9_-]{1,80}$/.test(id)) continue;
++ const reports = Number(b.item_count) || 0;
++ const meta = ['AI brief', reports ? `${reports} ${reports === 1 ? 'report' : 'reports'}` : '', ago(b.window_end, at)].filter(Boolean).join(' · ');
++ out.push({ title, meta: plain(meta, 120), url: `${BASE}/briefs/${id}` });
++ if (out.length >= LIMIT) break;
++ }
++ return {
++ id: CARD_ID,
++ site: SITE,
++ title: 'Latest AI briefs',
++ kind: 'rows',
++ items: out,
++ more: { label: 'All AI briefs', url: `${BASE}/briefs` },
++ updated_at: new Date(at).toISOString(),
++ ttl_s: CARD_TTL_S,
++ };
++}
++
++/**
++ * The route. `load(at)` returns reading.listItems() rows; `loadBriefs(at)` (optional) returns briefs.latest() rows
++ * for when no story is published; `now` is injectable for the test.
++ */
++function createMenuCard({ load, loadBriefs = null, now = Date.now } = {}) {
let cached = null;
let cachedAt = 0;
let pending = null;
@@@ -102,7 +135,9 @@ function createMenuCard({ load, now = Date.now } = {}) {
if (pending) return pending;
pending = (async () => {
try {
-- cached = cardFrom(await load(at), at);
++ let next = cardFrom(await load(at), at);
++ if (!next.items.length && loadBriefs) next = cardFromBriefs(await loadBriefs(at), at);
++ cached = next;
cachedAt = at;
} catch {
// Keep the last good card rather than publish an empty one on a database hiccup.
@@@ -124,4 +159,4 @@ function createMenuCard({ load, now = Date.now } = {}) {
return { card, handler, cardFrom, cacheMs: CACHE_MS };
}
--module.exports = { createMenuCard, cardFrom, startOfDay, ago, plain, BASE, CARD_ID, SITE, CARD_TTL_S, DAY, LIMIT };
++module.exports = { createMenuCard, cardFrom, cardFromBriefs, 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 ffd1715..5083131 100644
---- a/test/menu-card.test.js
++++ b/test/menu-card.test.js
@@@ -6,7 +6,7 @@
// 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, startOfDay, ago, plain, CARD_TTL_S } = require('../server/menu-card');
++const { createMenuCard, cardFrom, cardFromBriefs, 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');
@@@ -69,6 +69,14 @@ 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'));
++// ── No published story: the newest AI briefs, said to be AI briefs (11-07) ──
++const brief = (o = {}) => ({ kind: 'summary', stub: false, paragraphs: [{ text: 'It says.' }], cluster_id: 'cl_1', headline: 'EU aid for climate projects', item_count: 3, window_end: NOW - 2 * 3600_000, ...o });
++const fromBriefs = cardFromBriefs([brief(), brief({ cluster_id: 'cl_2', stub: true }), brief({ cluster_id: 'cl_3', paragraphs: [] }), brief({ cluster_id: 'cl_4', kind: 'event' }), brief({ cluster_id: 'bad id/x' })], NOW);
++assert.strictEqual(fromBriefs.title, 'Latest AI briefs');
++assert.deepStrictEqual(fromBriefs.items, [{ title: 'EU aid for climate projects', meta: 'AI brief · 3 reports · 2h ago', url: 'https://openvibe.news/briefs/cl_1' }], 'summaries with text only, each said to be an AI brief');
++assert.deepStrictEqual(fromBriefs.more, { label: 'All AI briefs', url: 'https://openvibe.news/briefs' });
++assert.ok(contracts.validate('frame.menu-cards@1', [fromBriefs]).valid);
++
// ── The five-minute cache and the route's headers ───────────────────────
(async () => {
let t = 1_000;
@@@ -134,5 +142,10 @@ assert.strictEqual(startOfDay(NOW), Date.parse('2026-11-07T00:00:00Z'));
await handle.close();
}
-- console.log('menu card: top/latest, contract, public listing, retracted filter, plain text, 5 min cache, CORS and last-good card');
++ // With no story the route falls back to the briefs; a story, when there is one, still wins.
++ const viaBriefs = createMenuCard({ load: async () => [], loadBriefs: async () => [brief()], now: () => NOW });
++ assert.strictEqual((await viaBriefs.card()).title, 'Latest AI briefs');
++ const storyWins = createMenuCard({ load: async () => [item()], loadBriefs: async () => [brief()], now: () => NOW });
++ assert.notStrictEqual((await storyWins.card()).title, 'Latest AI briefs');
++ console.log('menu card: top/latest, contract, public listing, retracted filter, plain text, 5 min cache, CORS last-good card, and AI briefs when no story is published');
})().catch((err) => { console.error(err); process.exit(1); });