openvibers/openvibe.today Commits

Menu: the next upcoming events within seven days (#116)

OP OpenVibers committed 7 days ago · 1ee1829216 · 3 files, +76 −40

Changes

ddiff --git a/server/app.js b/server/app.js
iindex f575a14..f834080 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -178,15 +178,15 @@ 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.
++ // The menu's network card (frame.menu-cards@1): the next public events within seven days. Only what the public
++ // schedule already shows (events.listPublic) is read, so private and followers-only events never reach it; the
++ // loader reads the window and the card keeps the upcoming events, in start order.
{
-- const { createMenuCard, startOfDay, DAY } = require('./menu-card');
++ const { createMenuCard, DAY, WINDOW_DAYS } = 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(),
++ from: new Date(at).toISOString(),
++ to: new Date(at + WINDOW_DAYS * DAY).toISOString(),
limit: 50, exceptKinds: ['holiday'],
}),
});
ddiff --git a/server/menu-card.js b/server/menu-card.js
iindex 6d6dd7e..2a6df09 100644
---- a/server/menu-card.js
++++ b/server/menu-card.js
@@@ -1,19 +1,21 @@
'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.
++// Today publishes its next real public events: everything still upcoming within the next seven days, in start
++// order, each with its own day, time and channel or place, linking to its event page. The title says which day
++// the first one is on ("Today", "Tomorrow" or "Next 7 days"). With nothing upcoming the card is still published,
++// with no items, and Network skips it: the schedule is honest rather than filled with stale events.
//
// 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.
++// agendas, reminders, followers-only events and holidays 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 WINDOW_DAYS = 7;
const LIMIT = 6;
/** Plain words for a card: no markup, no control characters, one line, within the schema's limit. */
@@@ -32,6 +34,16 @@ 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`);
++/** "Tomorrow", "Mon 10 Nov", or "" for an event later today. Days are UTC, like the card's window. */
++function dayLabel(iso, at) {
++ const start = Date.parse(String(iso || ''));
++ if (!Number.isFinite(start)) return '';
++ const key = dayKey(start);
++ if (key === dayKey(at)) return '';
++ if (key === dayKey(at + DAY)) return 'Tomorrow';
++ return new Intl.DateTimeFormat('en-GB', { timeZone: 'UTC', weekday: 'short', day: 'numeric', month: 'short' }).format(new Date(start));
++}
++
/** "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);
@@@ -61,30 +73,31 @@ function placeOf(e) {
/** The card itself. `events` are public events (events.listPublic rows); `at` is the build time. */
function cardFrom(events, at = Date.now()) {
++ const until = at + WINDOW_DAYS * DAY;
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;
++ return Number.isFinite(start) && start >= at && start <= until;
})
.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) {
++ let firstDay = null;
++ for (const e of upcoming) {
const title = plain(e.title, 80);
if (!title) continue;
-- const when = timeLabel(e.starts_at, e.tz);
++ if (firstDay === null) firstDay = dayKey(e.starts_at);
++ const when = [dayLabel(e.starts_at, at), timeLabel(e.starts_at, e.tz)].filter(Boolean).join(' · ');
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))}` });
++ if (items.length >= LIMIT) break;
}
++ const title = !items.length ? 'Today' : firstDay === dayKey(at) ? 'Today' : firstDay === dayKey(at + DAY) ? 'Tomorrow' : 'Next 7 days';
return {
id: CARD_ID,
site: SITE,
-- title: todayItems.length ? 'Today' : (items.length ? 'Tomorrow' : 'Today'),
++ title,
kind: 'rows',
items,
more: { label: 'Full schedule', url: `${BASE}/` },
@@@ -93,7 +106,7 @@ function cardFrom(events, at = Date.now()) {
};
}
--/** The route. `load(at)` returns the next two UTC days of public events; `now` is injectable for the test. */
++/** The route. `load(at)` returns the next seven UTC days of public events; `now` is injectable for the test. */
function createMenuCard({ load, now = Date.now } = {}) {
let cached = null;
let cachedAt = 0;
@@@ -108,7 +121,7 @@ function createMenuCard({ load, now = Date.now } = {}) {
cached = cardFrom(await load(at), at);
cachedAt = at;
} 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 schedule on a database hiccup.
if (!cached) { cached = cardFrom([], at); cachedAt = at; }
}
return cached;
@@@ -127,4 +140,4 @@ function createMenuCard({ load, now = Date.now } = {}) {
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 };
++module.exports = { createMenuCard, cardFrom, dayLabel, timeLabel, placeOf, dayKey, startOfDay, plain, BASE, CARD_ID, SITE, CARD_TTL_S, DAY, WINDOW_DAYS, LIMIT };
ddiff --git a/test/menu-card.test.js b/test/menu-card.test.js
iindex a14e62a..754fac3 100644
---- a/test/menu-card.test.js
++++ b/test/menu-card.test.js
@@@ -1,10 +1,12 @@
'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.
++// today's next public events within seven days — "Today", "Tomorrow" or "Next 7 days" — each with its own day,
++// time and channel or place. The array validates against frame.menu-cards@1, never carries a cancelled, done,
++// private or out-of-window event, is CORS * and cached for five minutes. With nothing upcoming the card stays
++// empty: the schedule is honest.
const assert = require('assert');
const contracts = require('openvibe-contracts');
--const { createMenuCard, cardFrom, timeLabel, placeOf, CARD_TTL_S } = require('../server/menu-card');
++const { createMenuCard, cardFrom, dayLabel, timeLabel, placeOf, CARD_TTL_S } = require('../server/menu-card');
const NOW = Date.parse('2026-11-07T12:00:00Z');
const event = (over = {}) => ({
@@@ -12,13 +14,18 @@ const event = (over = {}) => ({
tz: 'UTC', status: 'scheduled', kind: 'stream', visibility: 'public', url: null, ...over,
});
--// ── Today's card: shape, words and contract ─────────────────────────────
++// ── The card: shape, words, window 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' }),
++ event({ id: 'ev3', title: 'Tomorrow workshop', starts_at: '2026-11-08T10:00:00Z' }),
++ event({ id: 'ev4', title: 'Tuesday meetup', starts_at: '2026-11-10T09:00:00Z' }),
++ event({ id: 'too-far', title: 'Next week', starts_at: '2026-11-15T12:01:00Z' }),
++ event({ id: 'private', title: 'Followers only', visibility: 'followers' }),
++ event({ id: 'blank', title: ' ' }),
], NOW);
const checked = contracts.validate('frame.menu-cards@1', [card]);
assert.ok(checked.valid, JSON.stringify(checked.errors));
@@@ -31,33 +38,49 @@ 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' },
++ { title: 'Tomorrow workshop', meta: 'Tomorrow · 10:00 UTC · @nova', url: 'https://openvibe.today/e/ev3' },
++ { title: 'Tuesday meetup', meta: 'Tue 10 Nov · 09:00 UTC · @nova', url: 'https://openvibe.today/e/ev4' },
]);
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".
++// The title follows the first event's day; later within the week is "Next 7 days".
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' }),
++ event({ id: 'ev4', title: 'Tuesday meetup', starts_at: '2026-11-10T09:00:00Z' }),
], NOW);
assert.strictEqual(tomorrow.title, 'Tomorrow');
--assert.deepStrictEqual(tomorrow.items.map((i) => i.title), ['Tomorrow workshop', 'Tomorrow stream']);
++assert.deepStrictEqual(tomorrow.items.map((i) => i.title), ['Tomorrow workshop', 'Tuesday meetup']);
assert.ok(contracts.validate('frame.menu-cards@1', [tomorrow]).valid);
++const later = cardFrom([event({ id: 'ev4', title: 'Tuesday meetup', starts_at: '2026-11-10T09:00:00Z' })], NOW);
++assert.strictEqual(later.title, 'Next 7 days');
++assert.deepStrictEqual(later.items.map((i) => i.title), ['Tuesday meetup']);
--// 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, []);
++// No entries at all (or none the card may show, or none inside the week): an empty items array, still a valid
++// card, and Network skips it. Nothing stale is ever put there to fill the card.
++for (const rows of [
++ [event({ starts_at: '2026-11-07T09:00:00Z' })],
++ [event({ id: 'far', starts_at: '2026-11-15T12:01:00Z' })],
++ [event({ visibility: 'followers' }), event({ title: ' ' })],
++ null,
++]) {
++ const empty = cardFrom(rows, NOW);
++ assert.deepStrictEqual(empty.items, []);
++ assert.strictEqual(empty.title, 'Today');
++ assert.ok(contracts.validate('frame.menu-cards@1', [empty]).valid);
++}
// 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);
++const many = Array.from({ length: 9 }, (_, i) => event({ id: `ev${i}`, title: `Event ${i}`, starts_at: new Date(NOW + (i + 1) * 6 * 60 * 60_000).toISOString() }));
++const six = cardFrom(many, NOW);
++assert.strictEqual(six.items.length, 6);
++assert.deepStrictEqual(six.items.map((i) => i.title), ['Event 0', 'Event 1', 'Event 2', 'Event 3', 'Event 4', 'Event 5']);
--// Hours are shown in the event's own zone, and a place is the channel, the link's host, or the site.
++// Days, hours and places are plain words in the event's own zone.
++assert.strictEqual(dayLabel('2026-11-07T14:00:00Z', NOW), '');
++assert.strictEqual(dayLabel('2026-11-08T10:00:00Z', NOW), 'Tomorrow');
++assert.strictEqual(dayLabel('2026-11-10T09:00:00Z', NOW), 'Tue 10 Nov');
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');
@@@ -98,5 +121,5 @@ assert.strictEqual(placeOf(event({ channel: null })), 'OpenVibe.Today');
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');
++ console.log('menu card: 7-day window, day labels, empty schedule, plain text, 5 min cache, CORS and last-good card');
})().catch((err) => { console.error(err); process.exit(1); });