openvibers/openvibe.news Commits
With no published story, News's menu card is the latest AI briefs (#236)
Changes
@@@ -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);}@@@ -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 };@@@ -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); });
@@ -219,6 +219,8 @@ async function createApp(opts = {}) {
@@ -219,6 +219,8 @@ async function createApp(opts = {}) {
const { stories: rows } = await stories.listPublished({ limit: 20 });
const { stories: rows } = await stories.listPublished({ limit: 20 });
return await reading.listItems(rows);
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);
app.get('/.well-known/openvibe-menu.json', menuCard.handler);
}
}
@@ -7,7 +7,9 @@
@@ -7,7 +7,9 @@
// (stories.listPublished + reading.listItems), so the card can only show what an anonymous reader can already
// (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.
// 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 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 BASE = 'https://openvibe.news';
const CARD_ID = 'top-stories';
const CARD_ID = 'top-stories';
const SITE = 'news';
const SITE = 'news';
@@ -90,8 +92,39 @@ function cardFrom(items, at = Date.now()) {
@@ -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 cached = null;
let cachedAt = 0;
let cachedAt = 0;
let pending = null;
let pending = null;
@@ -102,7 +135,9 @@ function createMenuCard({ load, now = Date.now } = {}) {
@@ -102,7 +135,9 @@ 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), at);
+ let next = cardFrom(await load(at), at);
+ if (!next.items.length && loadBriefs) next = cardFromBriefs(await loadBriefs(at), at);
+ cached = next;
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.
@@ -124,4 +159,4 @@ function createMenuCard({ load, now = Date.now } = {}) {
@@ -124,4 +159,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, 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 };
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
// cached for five minutes. The public listing is exercised on a real (PGlite) database too.
// 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, 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 { testDb } = require('./helpers/db');
const { createStore } = require('../server/db');
const { createStore } = require('../server/db');
const { createStories } = require('../server/domain/stories');
const { createStories } = require('../server/domain/stories');
@@ -69,6 +69,14 @@ assert.strictEqual(ago(NOW - 3 * 60 * 60_000, NOW), '3h ago');
@@ -69,6 +69,14 @@ 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'));
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 ───────────────────────
// ── The five-minute cache and the route's headers ───────────────────────
(async () => {
(async () => {
let t = 1_000;
let t = 1_000;
@@ -134,5 +142,10 @@ assert.strictEqual(startOfDay(NOW), Date.parse('2026-11-07T00:00:00Z'));
@@ -134,5 +142,10 @@ assert.strictEqual(startOfDay(NOW), Date.parse('2026-11-07T00:00:00Z'));
await handle.close();
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); });
})().catch((err) => { console.error(err); process.exit(1); });