openvibers/openvibe.today Commits
Publish the Today card as a frame.menu-cards array (#111)
Changes
@@@ -15,7 +15,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.317.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.129.0","pg": "^8.23.0"@@@ -1150,9 +1150,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.312.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",-- "integrity": "sha512-2GsD53zuIedL3eW2LBlYQ68ODTHzoZJHrAmVnZCpiL7ZWnAXGwPW9dpQagLI9Zdkh3LQuuU5ae1YJI0K+acXbA==",++ "version": "0.317.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.317.0",++ "integrity": "sha512-qy9hGj4cl5MSHHcYmOKcrL1BmMrbQ9uJCGYH5zxhp3gu6RwZqu7kHLVWBCcy5y7SY8odkxNuVU5Ts/RksiivxQ==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -23,7 +23,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.317.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.129.0","pg": "^8.23.0"@@@ -178,6 +178,21 @@ async function createApp(opts = {}) {},}));++ // The menu's network card (frame.menu-cards@1): the next public events today, or tomorrow's. Only what the++ // public schedule already shows (events.listPublic) is read, so private and followers-only events never++ // reach it; the loader reads the next two UTC days and the card picks today's, else tomorrow's.++ {++ const { createMenuCard, startOfDay, DAY } = require('./menu-card');++ const menuCard = createMenuCard({++ load: async (at) => await events.listPublic({++ from: new Date(startOfDay(at)).toISOString(),++ to: new Date(startOfDay(at) + 2 * DAY).toISOString(),++ limit: 50, exceptKinds: ['holiday'],++ }),++ });++ app.get('/.well-known/openvibe-menu.json', menuCard.handler);++ }++// ── API ─────────────────────────────────────────────────app.use('/api/v1', rateLimit({ windowMs: 60_000, limit: Number(process.env.TODAY_API_RATE_LIMIT_PER_MIN) || 240, standardHeaders: true, legacyHeaders: false, handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }) }));app.use('/api/v1', createApi(ctx));nnew file mode 100644@@@ -0,0 +1,130 @@++'use strict';++// The menu's network card for OpenVibe.Today (frame.menu-card@1): GET /.well-known/openvibe-menu.json.++//++// Today publishes "Today": the next real public events scheduled for today (UTC), each with its own time and++// channel or place, linking to its event page. When nothing is left today the card moves to tomorrow and its++// title says "Tomorrow"; with nothing on either day it is still published, with no items, and Network skips it.++//++// Only public, non-cancelled events the public schedule already shows (events.listPublic) are read. Personal++// agendas, reminders and followers-only events never reach the card. The file is CORS *, cached for five++// minutes; a failed database keeps the last good card.++const BASE = 'https://openvibe.today';++const CARD_ID = 'today';++const SITE = 'today';++const CARD_TTL_S = 300;++const CACHE_MS = CARD_TTL_S * 1000;++const DAY = 24 * 60 * 60_000;++const LIMIT = 6;++++/** Plain words for a card: no markup, no control characters, one line, within the schema's limit. */++function plain(value, max) {++ return String(value == null ? '' : value)++ .replace(/<[^>]*>/g, ' ')++ .replace(/[\u0000-\u001f\u007f]+/g, ' ')++ .replace(/\s+/g, ' ')++ .trim()++ .slice(0, max);++}++++/** The UTC day of an instant, YYYY-MM-DD. The card is the same for every reader, so "today" is UTC. */++const dayKey = (value) => new Date(value).toISOString().slice(0, 10);++++/** The start of a UTC day, in milliseconds. */++const startOfDay = (value) => Date.parse(`${dayKey(value)}T00:00:00.000Z`);++++/** "14:00 UTC" in the event's own zone (UTC when it has none or it is unknown). */++function timeLabel(iso, tz) {++ const d = new Date(iso);++ if (!Number.isFinite(d.getTime())) return '';++ const zone = tz || 'UTC';++ let parts;++ try {++ parts = new Intl.DateTimeFormat('en-GB', { timeZone: zone, hour: '2-digit', minute: '2-digit', hour12: false, timeZoneName: 'short' }).formatToParts(d);++ } catch {++ parts = new Intl.DateTimeFormat('en-GB', { timeZone: 'UTC', hour: '2-digit', minute: '2-digit', hour12: false, timeZoneName: 'short' }).formatToParts(d);++ }++ const get = (type) => (parts.find((p) => p.type === type) || {}).value || '';++ const time = `${get('hour')}:${get('minute')}`;++ const zoneName = get('timeZoneName');++ return [time, zoneName].filter(Boolean).join(' ');++}++++/** Where an event happens: its channel when it has one, else the host its link names, else the site. */++function placeOf(e) {++ if (e.channel) return `@${String(e.channel)}`;++ try {++ const host = new URL(String(e.url || '')).hostname.replace(/^www\./, '');++ if (host) return host;++ } catch { /* no link */ }++ return 'OpenVibe.Today';++}++++/** The card itself. `events` are public events (events.listPublic rows); `at` is the build time. */++function cardFrom(events, at = Date.now()) {++ const upcoming = (Array.isArray(events) ? events : [])++ .filter((e) => e && e.starts_at && e.status !== 'cancelled' && e.status !== 'done')++ .filter((e) => !e.visibility || e.visibility === 'public')++ .filter((e) => {++ const start = Date.parse(String(e.starts_at));++ return Number.isFinite(start) && start >= at;++ })++ .sort((a, b) => Date.parse(a.starts_at) - Date.parse(b.starts_at));++ const today = dayKey(at);++ const tomorrow = dayKey(at + DAY);++ const todayItems = upcoming.filter((e) => dayKey(e.starts_at) === today).slice(0, LIMIT);++ const next = todayItems.length ? todayItems : upcoming.filter((e) => dayKey(e.starts_at) === tomorrow).slice(0, LIMIT);++ const items = [];++ for (const e of next) {++ const title = plain(e.title, 80);++ if (!title) continue;++ const when = timeLabel(e.starts_at, e.tz);++ const meta = [when, plain(placeOf(e), 60)].filter(Boolean).join(' · ');++ items.push({ title, ...(meta ? { meta: plain(meta, 120) } : {}), url: `${BASE}/e/${encodeURIComponent(String(e.id))}` });++ }++ return {++ id: CARD_ID,++ site: SITE,++ title: todayItems.length ? 'Today' : (items.length ? 'Tomorrow' : 'Today'),++ kind: 'rows',++ items,++ more: { label: 'Full schedule', url: `${BASE}/` },++ updated_at: new Date(at).toISOString(),++ ttl_s: CARD_TTL_S,++ };++}++++/** The route. `load(at)` returns the next two UTC days of public events; `now` is injectable for the test. */++function createMenuCard({ load, now = Date.now } = {}) {++ let cached = null;++ let cachedAt = 0;++ let pending = null;++++ async function card() {++ const at = now();++ if (cached && at - cachedAt < CACHE_MS) return cached;++ if (pending) return pending;++ pending = (async () => {++ try {++ cached = cardFrom(await load(at), at);++ cachedAt = at;++ } catch {++ // Keep the last good card rather than publish an empty one on a database hiccup.++ if (!cached) { cached = cardFrom([], at); cachedAt = at; }++ }++ return cached;++ })().finally(() => { pending = null; });++ return pending;++ }++++ async function handler(_req, res) {++ const body = await card();++ res.set('Cache-Control', 'public, max-age=60')++ .set('Access-Control-Allow-Origin', '*')++ .set('Cross-Origin-Resource-Policy', 'cross-origin')++ .json([body]);++ }++++ return { card, handler, cardFrom, cacheMs: CACHE_MS };++}++++module.exports = { createMenuCard, cardFrom, timeLabel, placeOf, dayKey, startOfDay, plain, BASE, CARD_ID, SITE, CARD_TTL_S, DAY, LIMIT };nnew file mode 100644@@@ -0,0 +1,102 @@++'use strict';++// The menu's network card for OpenVibe.Today (frame.menu-card@1): GET /.well-known/openvibe-menu.json serves++// "Today" — the next real public events left today, or tomorrow's with the title "Tomorrow". The array validates++// against frame.menu-cards@1, never carries a cancelled, done or private event, is CORS * and cached for five minutes.++const assert = require('assert');++const contracts = require('openvibe-contracts');++const { createMenuCard, cardFrom, timeLabel, placeOf, CARD_TTL_S } = require('../server/menu-card');++++const NOW = Date.parse('2026-11-07T12:00:00Z');++const event = (over = {}) => ({++ id: 'ev1', title: 'A real stream', channel: 'nova', starts_at: '2026-11-07T14:00:00Z',++ tz: 'UTC', status: 'scheduled', kind: 'stream', visibility: 'public', url: null, ...over,++});++++// ── Today's card: shape, words and contract ─────────────────────────────++const card = cardFrom([++ event({ id: 'past', starts_at: '2026-11-07T09:00:00Z' }),++ event({ id: 'done', status: 'done' }),++ event({ id: 'gone', status: 'cancelled' }),++ event({ id: 'ev2', title: '<b>Later</b>\nshow', channel: null, url: 'https://openvibe.live/@kitchen', starts_at: '2026-11-07T18:30:00Z' }),++ event({ id: 'ev1', title: 'A real stream', starts_at: '2026-11-07T14:00:00Z' }),++], NOW);++const checked = contracts.validate('frame.menu-cards@1', [card]);++assert.ok(checked.valid, JSON.stringify(checked.errors));++assert.deepStrictEqual(Object.keys(card).sort(), ['id', 'items', 'kind', 'more', 'site', 'title', 'ttl_s', 'updated_at']);++assert.strictEqual(card.id, 'today');++assert.strictEqual(card.site, 'today');++assert.strictEqual(card.title, 'Today');++assert.strictEqual(card.kind, 'rows');++assert.strictEqual(card.ttl_s, CARD_TTL_S);++assert.deepStrictEqual(card.items, [++ { title: 'A real stream', meta: '14:00 UTC · @nova', url: 'https://openvibe.today/e/ev1' },++ { title: 'Later show', meta: '18:30 UTC · openvibe.live', url: 'https://openvibe.today/e/ev2' },++]);++assert.deepStrictEqual(card.more, { label: 'Full schedule', url: 'https://openvibe.today/' });++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.today/e/'));++++// Nothing left today: tomorrow's events are shown and the card says "Tomorrow".++const tomorrow = cardFrom([++ event({ id: 'ev3', title: 'Tomorrow workshop', starts_at: '2026-11-08T10:00:00Z' }),++ event({ id: 'ev4', title: 'Tomorrow stream', starts_at: '2026-11-08T20:00:00Z' }),++], NOW);++assert.strictEqual(tomorrow.title, 'Tomorrow');++assert.deepStrictEqual(tomorrow.items.map((i) => i.title), ['Tomorrow workshop', 'Tomorrow stream']);++assert.ok(contracts.validate('frame.menu-cards@1', [tomorrow]).valid);++++// No entries at all (or none the card may show): an empty items array, still a valid card, and Network skips it.++const empty = cardFrom([event({ starts_at: '2026-11-07T09:00:00Z' })], NOW);++assert.deepStrictEqual(empty.items, []);++assert.strictEqual(empty.title, 'Today');++assert.ok(contracts.validate('frame.menu-cards@1', [empty]).valid);++assert.deepStrictEqual(cardFrom(null, NOW).items, []);++assert.deepStrictEqual(cardFrom([event({ visibility: 'followers' }), event({ title: ' ' })], NOW).items, []);++++// At most six entries, in start order.++const many = Array.from({ length: 9 }, (_, i) => event({ id: `ev${i}`, title: `Event ${i}`, starts_at: `2026-11-07T${String(13 + i).padStart(2, '0')}:00:00Z` }));++assert.strictEqual(cardFrom(many, NOW).items.length, 6);++++// Hours are shown in the event's own zone, and a place is the channel, the link's host, or the site.++assert.strictEqual(timeLabel('2026-11-07T14:00:00Z', 'UTC'), '14:00 UTC');++assert.ok(timeLabel('2026-11-07T14:00:00Z', 'America/New_York').startsWith('09:00 '), 'the event\'s own zone is named');++assert.strictEqual(placeOf(event()), '@nova');++assert.strictEqual(placeOf(event({ channel: null, url: 'https://openvibe.live/@kitchen' })), 'openvibe.live');++assert.strictEqual(placeOf(event({ channel: null })), 'OpenVibe.Today');++++// ── The five-minute cache and the route's headers ───────────────────────++(async () => {++ let t = NOW;++ let loads = 0;++ const menu = createMenuCard({ load: async () => { loads++; return [event()]; }, now: () => t });++ const first = await menu.card();++ assert.strictEqual(loads, 1);++ assert.strictEqual(await menu.card(), first);++ assert.strictEqual(loads, 1, 'a second request inside the ttl is served from the cache');++ t += CARD_TTL_S * 1000 + 1;++ await menu.card();++ assert.strictEqual(loads, 2, 'after the ttl the card is rebuilt');++++ const headers = {};++ let body = null;++ const res = {++ set(k, v) { headers[k] = v; return this; },++ json(v) { body = v; return this; },++ };++ await menu.handler({}, res);++ assert.strictEqual(headers['Access-Control-Allow-Origin'], '*');++ assert.strictEqual(headers['Cross-Origin-Resource-Policy'], 'cross-origin');++ assert.strictEqual(headers['Cache-Control'], 'public, max-age=60');++ assert.ok(Array.isArray(body) && body.length === 1, 'the file is an array of cards');++ assert.ok(contracts.validate('frame.menu-cards@1', body).valid);++++ // A failing database keeps the last good card rather than publishing an empty schedule.++ let fail = false;++ const flaky = createMenuCard({ load: async () => { if (fail) throw new Error('db down'); return [event()]; }, now: () => t });++ const good = await flaky.card();++ fail = true;++ t += CARD_TTL_S * 1000 + 1;++ assert.strictEqual(await flaky.card(), good);++++ console.log('menu card: shape, contract, today/tomorrow, empty schedule, plain text, 5 min cache, CORS and last-good card');++})().catch((err) => { console.error(err); process.exit(1); });
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.317.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.129.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.129.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -1150,9 +1150,9 @@
@@ -1150,9 +1150,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.312.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",
- "integrity": "sha512-2GsD53zuIedL3eW2LBlYQ68ODTHzoZJHrAmVnZCpiL7ZWnAXGwPW9dpQagLI9Zdkh3LQuuU5ae1YJI0K+acXbA==",
+ "version": "0.317.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.317.0",
+ "integrity": "sha512-qy9hGj4cl5MSHHcYmOKcrL1BmMrbQ9uJCGYH5zxhp3gu6RwZqu7kHLVWBCcy5y7SY8odkxNuVU5Ts/RksiivxQ==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -23,7 +23,7 @@
@@ -23,7 +23,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.317.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.129.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.129.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -178,6 +178,21 @@ async function createApp(opts = {}) {
@@ -178,6 +178,21 @@ async function createApp(opts = {}) {
},
},
}));
}));
+ // The menu's network card (frame.menu-cards@1): the next public events today, or tomorrow's. Only what the
+ // public schedule already shows (events.listPublic) is read, so private and followers-only events never
+ // reach it; the loader reads the next two UTC days and the card picks today's, else tomorrow's.
+ {
+ const { createMenuCard, startOfDay, DAY } = require('./menu-card');
+ const menuCard = createMenuCard({
+ load: async (at) => await events.listPublic({
+ from: new Date(startOfDay(at)).toISOString(),
+ to: new Date(startOfDay(at) + 2 * DAY).toISOString(),
+ limit: 50, exceptKinds: ['holiday'],
+ }),
+ });
+ app.get('/.well-known/openvibe-menu.json', menuCard.handler);
+ }
+
// ── API ─────────────────────────────────────────────────
// ── API ─────────────────────────────────────────────────
app.use('/api/v1', rateLimit({ windowMs: 60_000, limit: Number(process.env.TODAY_API_RATE_LIMIT_PER_MIN) || 240, standardHeaders: true, legacyHeaders: false, handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }) }));
app.use('/api/v1', rateLimit({ windowMs: 60_000, limit: Number(process.env.TODAY_API_RATE_LIMIT_PER_MIN) || 240, standardHeaders: true, legacyHeaders: false, handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }) }));
app.use('/api/v1', createApi(ctx));
app.use('/api/v1', createApi(ctx));
new file mode 100644
new file mode 100644
@@ -0,0 +1,130 @@
@@ -0,0 +1,130 @@
+'use strict';
+// The menu's network card for OpenVibe.Today (frame.menu-card@1): GET /.well-known/openvibe-menu.json.
+//
+// Today publishes "Today": the next real public events scheduled for today (UTC), each with its own time and
+// channel or place, linking to its event page. When nothing is left today the card moves to tomorrow and its
+// title says "Tomorrow"; with nothing on either day it is still published, with no items, and Network skips it.
+//
+// Only public, non-cancelled events the public schedule already shows (events.listPublic) are read. Personal
+// agendas, reminders and followers-only events never reach the card. The file is CORS *, cached for five
+// minutes; a failed database keeps the last good card.
+const BASE = 'https://openvibe.today';
+const CARD_ID = 'today';
+const SITE = 'today';
+const CARD_TTL_S = 300;
+const CACHE_MS = CARD_TTL_S * 1000;
+const DAY = 24 * 60 * 60_000;
+const LIMIT = 6;
+
+/** Plain words for a card: no markup, no control characters, one line, within the schema's limit. */
+function plain(value, max) {
+ return String(value == null ? '' : value)
+ .replace(/<[^>]*>/g, ' ')
+ .replace(/[\u0000-\u001f\u007f]+/g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim()
+ .slice(0, max);
+}
+
+/** The UTC day of an instant, YYYY-MM-DD. The card is the same for every reader, so "today" is UTC. */
+const dayKey = (value) => new Date(value).toISOString().slice(0, 10);
+
+/** The start of a UTC day, in milliseconds. */
+const startOfDay = (value) => Date.parse(`${dayKey(value)}T00:00:00.000Z`);
+
+/** "14:00 UTC" in the event's own zone (UTC when it has none or it is unknown). */
+function timeLabel(iso, tz) {
+ const d = new Date(iso);
+ if (!Number.isFinite(d.getTime())) return '';
+ const zone = tz || 'UTC';
+ let parts;
+ try {
+ parts = new Intl.DateTimeFormat('en-GB', { timeZone: zone, hour: '2-digit', minute: '2-digit', hour12: false, timeZoneName: 'short' }).formatToParts(d);
+ } catch {
+ parts = new Intl.DateTimeFormat('en-GB', { timeZone: 'UTC', hour: '2-digit', minute: '2-digit', hour12: false, timeZoneName: 'short' }).formatToParts(d);
+ }
+ const get = (type) => (parts.find((p) => p.type === type) || {}).value || '';
+ const time = `${get('hour')}:${get('minute')}`;
+ const zoneName = get('timeZoneName');
+ return [time, zoneName].filter(Boolean).join(' ');
+}
+
+/** Where an event happens: its channel when it has one, else the host its link names, else the site. */
+function placeOf(e) {
+ if (e.channel) return `@${String(e.channel)}`;
+ try {
+ const host = new URL(String(e.url || '')).hostname.replace(/^www\./, '');
+ if (host) return host;
+ } catch { /* no link */ }
+ return 'OpenVibe.Today';
+}
+
+/** The card itself. `events` are public events (events.listPublic rows); `at` is the build time. */
+function cardFrom(events, at = Date.now()) {
+ const upcoming = (Array.isArray(events) ? events : [])
+ .filter((e) => e && e.starts_at && e.status !== 'cancelled' && e.status !== 'done')
+ .filter((e) => !e.visibility || e.visibility === 'public')
+ .filter((e) => {
+ const start = Date.parse(String(e.starts_at));
+ return Number.isFinite(start) && start >= at;
+ })
+ .sort((a, b) => Date.parse(a.starts_at) - Date.parse(b.starts_at));
+ const today = dayKey(at);
+ const tomorrow = dayKey(at + DAY);
+ const todayItems = upcoming.filter((e) => dayKey(e.starts_at) === today).slice(0, LIMIT);
+ const next = todayItems.length ? todayItems : upcoming.filter((e) => dayKey(e.starts_at) === tomorrow).slice(0, LIMIT);
+ const items = [];
+ for (const e of next) {
+ const title = plain(e.title, 80);
+ if (!title) continue;
+ const when = timeLabel(e.starts_at, e.tz);
+ const meta = [when, plain(placeOf(e), 60)].filter(Boolean).join(' · ');
+ items.push({ title, ...(meta ? { meta: plain(meta, 120) } : {}), url: `${BASE}/e/${encodeURIComponent(String(e.id))}` });
+ }
+ return {
+ id: CARD_ID,
+ site: SITE,
+ title: todayItems.length ? 'Today' : (items.length ? 'Tomorrow' : 'Today'),
+ kind: 'rows',
+ items,
+ more: { label: 'Full schedule', url: `${BASE}/` },
+ updated_at: new Date(at).toISOString(),
+ ttl_s: CARD_TTL_S,
+ };
+}
+
+/** The route. `load(at)` returns the next two UTC days of public events; `now` is injectable for the test. */
+function createMenuCard({ load, now = Date.now } = {}) {
+ let cached = null;
+ let cachedAt = 0;
+ let pending = null;
+
+ async function card() {
+ const at = now();
+ if (cached && at - cachedAt < CACHE_MS) return cached;
+ if (pending) return pending;
+ pending = (async () => {
+ try {
+ cached = cardFrom(await load(at), at);
+ cachedAt = at;
+ } catch {
+ // Keep the last good card rather than publish an empty one on a database hiccup.
+ if (!cached) { cached = cardFrom([], at); cachedAt = at; }
+ }
+ return cached;
+ })().finally(() => { pending = null; });
+ return pending;
+ }
+
+ async function handler(_req, res) {
+ const body = await card();
+ res.set('Cache-Control', 'public, max-age=60')
+ .set('Access-Control-Allow-Origin', '*')
+ .set('Cross-Origin-Resource-Policy', 'cross-origin')
+ .json([body]);
+ }
+
+ return { card, handler, cardFrom, cacheMs: CACHE_MS };
+}
+
+module.exports = { createMenuCard, cardFrom, timeLabel, placeOf, dayKey, startOfDay, plain, BASE, CARD_ID, SITE, CARD_TTL_S, DAY, LIMIT };
new file mode 100644
new file mode 100644
@@ -0,0 +1,102 @@
@@ -0,0 +1,102 @@
+'use strict';
+// The menu's network card for OpenVibe.Today (frame.menu-card@1): GET /.well-known/openvibe-menu.json serves
+// "Today" — the next real public events left today, or tomorrow's with the title "Tomorrow". The array validates
+// against frame.menu-cards@1, never carries a cancelled, done or private event, is CORS * and cached for five minutes.
+const assert = require('assert');
+const contracts = require('openvibe-contracts');
+const { createMenuCard, cardFrom, timeLabel, placeOf, CARD_TTL_S } = require('../server/menu-card');
+
+const NOW = Date.parse('2026-11-07T12:00:00Z');
+const event = (over = {}) => ({
+ id: 'ev1', title: 'A real stream', channel: 'nova', starts_at: '2026-11-07T14:00:00Z',
+ tz: 'UTC', status: 'scheduled', kind: 'stream', visibility: 'public', url: null, ...over,
+});
+
+// ── Today's card: shape, words and contract ─────────────────────────────
+const card = cardFrom([
+ event({ id: 'past', starts_at: '2026-11-07T09:00:00Z' }),
+ event({ id: 'done', status: 'done' }),
+ event({ id: 'gone', status: 'cancelled' }),
+ event({ id: 'ev2', title: '<b>Later</b>\nshow', channel: null, url: 'https://openvibe.live/@kitchen', starts_at: '2026-11-07T18:30:00Z' }),
+ event({ id: 'ev1', title: 'A real stream', starts_at: '2026-11-07T14:00:00Z' }),
+], NOW);
+const checked = contracts.validate('frame.menu-cards@1', [card]);
+assert.ok(checked.valid, JSON.stringify(checked.errors));
+assert.deepStrictEqual(Object.keys(card).sort(), ['id', 'items', 'kind', 'more', 'site', 'title', 'ttl_s', 'updated_at']);
+assert.strictEqual(card.id, 'today');
+assert.strictEqual(card.site, 'today');
+assert.strictEqual(card.title, 'Today');
+assert.strictEqual(card.kind, 'rows');
+assert.strictEqual(card.ttl_s, CARD_TTL_S);
+assert.deepStrictEqual(card.items, [
+ { title: 'A real stream', meta: '14:00 UTC · @nova', url: 'https://openvibe.today/e/ev1' },
+ { title: 'Later show', meta: '18:30 UTC · openvibe.live', url: 'https://openvibe.today/e/ev2' },
+]);
+assert.deepStrictEqual(card.more, { label: 'Full schedule', url: 'https://openvibe.today/' });
+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.today/e/'));
+
+// Nothing left today: tomorrow's events are shown and the card says "Tomorrow".
+const tomorrow = cardFrom([
+ event({ id: 'ev3', title: 'Tomorrow workshop', starts_at: '2026-11-08T10:00:00Z' }),
+ event({ id: 'ev4', title: 'Tomorrow stream', starts_at: '2026-11-08T20:00:00Z' }),
+], NOW);
+assert.strictEqual(tomorrow.title, 'Tomorrow');
+assert.deepStrictEqual(tomorrow.items.map((i) => i.title), ['Tomorrow workshop', 'Tomorrow stream']);
+assert.ok(contracts.validate('frame.menu-cards@1', [tomorrow]).valid);
+
+// No entries at all (or none the card may show): an empty items array, still a valid card, and Network skips it.
+const empty = cardFrom([event({ starts_at: '2026-11-07T09:00:00Z' })], NOW);
+assert.deepStrictEqual(empty.items, []);
+assert.strictEqual(empty.title, 'Today');
+assert.ok(contracts.validate('frame.menu-cards@1', [empty]).valid);
+assert.deepStrictEqual(cardFrom(null, NOW).items, []);
+assert.deepStrictEqual(cardFrom([event({ visibility: 'followers' }), event({ title: ' ' })], NOW).items, []);
+
+// At most six entries, in start order.
+const many = Array.from({ length: 9 }, (_, i) => event({ id: `ev${i}`, title: `Event ${i}`, starts_at: `2026-11-07T${String(13 + i).padStart(2, '0')}:00:00Z` }));
+assert.strictEqual(cardFrom(many, NOW).items.length, 6);
+
+// Hours are shown in the event's own zone, and a place is the channel, the link's host, or the site.
+assert.strictEqual(timeLabel('2026-11-07T14:00:00Z', 'UTC'), '14:00 UTC');
+assert.ok(timeLabel('2026-11-07T14:00:00Z', 'America/New_York').startsWith('09:00 '), 'the event\'s own zone is named');
+assert.strictEqual(placeOf(event()), '@nova');
+assert.strictEqual(placeOf(event({ channel: null, url: 'https://openvibe.live/@kitchen' })), 'openvibe.live');
+assert.strictEqual(placeOf(event({ channel: null })), 'OpenVibe.Today');
+
+// ── The five-minute cache and the route's headers ───────────────────────
+(async () => {
+ let t = NOW;
+ let loads = 0;
+ const menu = createMenuCard({ load: async () => { loads++; return [event()]; }, now: () => t });
+ const first = await menu.card();
+ assert.strictEqual(loads, 1);
+ assert.strictEqual(await menu.card(), first);
+ assert.strictEqual(loads, 1, 'a second request inside the ttl is served from the cache');
+ t += CARD_TTL_S * 1000 + 1;
+ await menu.card();
+ assert.strictEqual(loads, 2, 'after the ttl the card is rebuilt');
+
+ const headers = {};
+ let body = null;
+ const res = {
+ set(k, v) { headers[k] = v; return this; },
+ json(v) { body = v; return this; },
+ };
+ await menu.handler({}, res);
+ assert.strictEqual(headers['Access-Control-Allow-Origin'], '*');
+ assert.strictEqual(headers['Cross-Origin-Resource-Policy'], 'cross-origin');
+ assert.strictEqual(headers['Cache-Control'], 'public, max-age=60');
+ assert.ok(Array.isArray(body) && body.length === 1, 'the file is an array of cards');
+ assert.ok(contracts.validate('frame.menu-cards@1', body).valid);
+
+ // A failing database keeps the last good card rather than publishing an empty schedule.
+ let fail = false;
+ const flaky = createMenuCard({ load: async () => { if (fail) throw new Error('db down'); return [event()]; }, now: () => t });
+ const good = await flaky.card();
+ fail = true;
+ t += CARD_TTL_S * 1000 + 1;
+ assert.strictEqual(await flaky.card(), good);
+
+ console.log('menu card: shape, contract, today/tomorrow, empty schedule, plain text, 5 min cache, CORS and last-good card');
+})().catch((err) => { console.error(err); process.exit(1); });