openvibers/openvibe.today Commits
Today's menu card shows the week's holidays when nothing else is on (#117)
Changes
@@@ -182,13 +182,9 @@ async function createApp(opts = {}) {// 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, DAY, WINDOW_DAYS } = require('./menu-card');++ const { createMenuCard, upcomingRows } = require('./menu-card');const menuCard = createMenuCard({-- load: async (at) => await events.listPublic({-- from: new Date(at).toISOString(),-- to: new Date(at + WINDOW_DAYS * DAY).toISOString(),-- limit: 50, exceptKinds: ['holiday'],-- }),++ load: (at) => upcomingRows(events, at),});app.get('/.well-known/openvibe-menu.json', menuCard.handler);}@@@ -7,7 +7,8 @@// 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, followers-only events and holidays never reach the card. The file is CORS *, cached for++// agendas, reminders and followers-only events never reach the card, and holidays only when no other public event++// is on this week (server/app.js). 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';@@@ -88,8 +89,10 @@ function cardFrom(events, at = Date.now()) {const title = plain(e.title, 80);if (!title) continue;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(' · ');++ // A holiday is a whole day: its day and the word, not a 00:00 start time or a place.++ const meta = e.kind === 'holiday'++ ? [dayLabel(e.starts_at, at), 'Holiday'].filter(Boolean).join(' · ')++ : [[dayLabel(e.starts_at, at), timeLabel(e.starts_at, e.tz)].filter(Boolean).join(' · '), 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;}@@@ -140,4 +143,14 @@ function createMenuCard({ load, now = Date.now } = {}) {return { card, handler, cardFrom, cacheMs: CACHE_MS };}--module.exports = { createMenuCard, cardFrom, dayLabel, timeLabel, placeOf, dayKey, startOfDay, plain, BASE, CARD_ID, SITE, CARD_TTL_S, DAY, WINDOW_DAYS, LIMIT };++/**++ * The rows the card reads: this week's public events, and holidays only when no other public event is on (11-07:++ * the only rows were holidays, so the card was empty and the menu left Today out). A real event always comes first.++ */++async function upcomingRows(events, at = Date.now()) {++ const window = { from: new Date(at).toISOString(), to: new Date(at + WINDOW_DAYS * DAY).toISOString(), limit: 50 };++ const rows = await events.listPublic({ ...window, exceptKinds: ['holiday'] });++ return rows.length ? rows : await events.listPublic({ ...window, kinds: ['holiday'] });++}++++module.exports = { upcomingRows, createMenuCard, cardFrom, dayLabel, timeLabel, placeOf, dayKey, startOfDay, plain, BASE, CARD_ID, SITE, CARD_TTL_S, DAY, WINDOW_DAYS, LIMIT };@@@ -6,7 +6,7 @@// empty: the schedule is honest.const assert = require('assert');const contracts = require('openvibe-contracts');--const { createMenuCard, cardFrom, dayLabel, timeLabel, placeOf, CARD_TTL_S } = require('../server/menu-card');++const { createMenuCard, cardFrom, dayLabel, timeLabel, placeOf, upcomingRows, CARD_TTL_S } = require('../server/menu-card');const NOW = Date.parse('2026-11-07T12:00:00Z');const event = (over = {}) => ({@@@ -28,6 +28,21 @@ const card = cardFrom([event({ id: 'blank', title: ' ' }),], NOW);const checked = contracts.validate('frame.menu-cards@1', [card]);++++// ── Holidays only when nothing else is on this week (11-07) ──++(async () => {++ const asked = [];++ const store = (rows) => ({ listPublic: async (q) => { asked.push(q); return rows(q); } });++ const holiday = event({ id: 'h1', title: 'Veterans Day', kind: 'holiday', starts_at: '2026-11-11T00:00:00Z' });++ const quiet = await upcomingRows(store((q) => (q.kinds ? [holiday] : [])), NOW);++ assert.deepStrictEqual(quiet.map((r) => r.id), ['h1'], 'a quiet week shows its holidays');++ assert.deepStrictEqual(cardFrom(quiet, NOW).items[0].meta, 'Wed 11 Nov · Holiday', 'a holiday reads as a day, not 00:00');++ assert.deepStrictEqual(asked.map((q) => [q.exceptKinds || null, q.kinds || null]), [[['holiday'], null], [null, ['holiday']]]);++ asked.length = 0;++ const busy = await upcomingRows(store((q) => (q.kinds ? [holiday] : [event()])), NOW);++ assert.deepStrictEqual(busy.map((r) => r.id), ['ev1'], 'a real event comes first, and holidays are not asked for');++ assert.strictEqual(asked.length, 1);++})().catch((e) => { console.error(e); process.exit(1); });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');
@@ -182,13 +182,9 @@ async function createApp(opts = {}) {
@@ -182,13 +182,9 @@ async function createApp(opts = {}) {
// schedule already shows (events.listPublic) is read, so private and followers-only events never reach it; the
// 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.
// loader reads the window and the card keeps the upcoming events, in start order.
{
{
- const { createMenuCard, DAY, WINDOW_DAYS } = require('./menu-card');
+ const { createMenuCard, upcomingRows } = require('./menu-card');
const menuCard = createMenuCard({
const menuCard = createMenuCard({
- load: async (at) => await events.listPublic({
- from: new Date(at).toISOString(),
- to: new Date(at + WINDOW_DAYS * DAY).toISOString(),
- limit: 50, exceptKinds: ['holiday'],
- }),
+ load: (at) => upcomingRows(events, at),
});
});
app.get('/.well-known/openvibe-menu.json', menuCard.handler);
app.get('/.well-known/openvibe-menu.json', menuCard.handler);
}
}
@@ -7,7 +7,8 @@
@@ -7,7 +7,8 @@
// with no items, and Network skips it: the schedule is honest rather than filled with stale events.
// 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
// Only public, non-cancelled events the public schedule already shows (events.listPublic) are read. Personal
-// agendas, reminders, followers-only events and holidays never reach the card. The file is CORS *, cached for
+// agendas, reminders and followers-only events never reach the card, and holidays only when no other public event
+// is on this week (server/app.js). The file is CORS *, cached for
// five minutes; a failed database keeps the last good card.
// five minutes; a failed database keeps the last good card.
const BASE = 'https://openvibe.today';
const BASE = 'https://openvibe.today';
const CARD_ID = 'today';
const CARD_ID = 'today';
@@ -88,8 +89,10 @@ function cardFrom(events, at = Date.now()) {
@@ -88,8 +89,10 @@ function cardFrom(events, at = Date.now()) {
const title = plain(e.title, 80);
const title = plain(e.title, 80);
if (!title) continue;
if (!title) continue;
if (firstDay === null) firstDay = dayKey(e.starts_at);
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(' · ');
+ // A holiday is a whole day: its day and the word, not a 00:00 start time or a place.
+ const meta = e.kind === 'holiday'
+ ? [dayLabel(e.starts_at, at), 'Holiday'].filter(Boolean).join(' · ')
+ : [[dayLabel(e.starts_at, at), timeLabel(e.starts_at, e.tz)].filter(Boolean).join(' · '), plain(placeOf(e), 60)].filter(Boolean).join(' · ');
items.push({ title, ...(meta ? { meta: plain(meta, 120) } : {}), url: `${BASE}/e/${encodeURIComponent(String(e.id))}` });
items.push({ title, ...(meta ? { meta: plain(meta, 120) } : {}), url: `${BASE}/e/${encodeURIComponent(String(e.id))}` });
if (items.length >= LIMIT) break;
if (items.length >= LIMIT) break;
}
}
@@ -140,4 +143,14 @@ function createMenuCard({ load, now = Date.now } = {}) {
@@ -140,4 +143,14 @@ function createMenuCard({ load, now = Date.now } = {}) {
return { card, handler, cardFrom, cacheMs: CACHE_MS };
return { card, handler, cardFrom, cacheMs: CACHE_MS };
}
}
-module.exports = { createMenuCard, cardFrom, dayLabel, timeLabel, placeOf, dayKey, startOfDay, plain, BASE, CARD_ID, SITE, CARD_TTL_S, DAY, WINDOW_DAYS, LIMIT };
+/**
+ * The rows the card reads: this week's public events, and holidays only when no other public event is on (11-07:
+ * the only rows were holidays, so the card was empty and the menu left Today out). A real event always comes first.
+ */
+async function upcomingRows(events, at = Date.now()) {
+ const window = { from: new Date(at).toISOString(), to: new Date(at + WINDOW_DAYS * DAY).toISOString(), limit: 50 };
+ const rows = await events.listPublic({ ...window, exceptKinds: ['holiday'] });
+ return rows.length ? rows : await events.listPublic({ ...window, kinds: ['holiday'] });
+}
+
+module.exports = { upcomingRows, createMenuCard, cardFrom, dayLabel, timeLabel, placeOf, dayKey, startOfDay, plain, BASE, CARD_ID, SITE, CARD_TTL_S, DAY, WINDOW_DAYS, LIMIT };
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
// empty: the schedule is honest.
// empty: the schedule is honest.
const assert = require('assert');
const assert = require('assert');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
-const { createMenuCard, cardFrom, dayLabel, timeLabel, placeOf, CARD_TTL_S } = require('../server/menu-card');
+const { createMenuCard, cardFrom, dayLabel, timeLabel, placeOf, upcomingRows, CARD_TTL_S } = require('../server/menu-card');
const NOW = Date.parse('2026-11-07T12:00:00Z');
const NOW = Date.parse('2026-11-07T12:00:00Z');
const event = (over = {}) => ({
const event = (over = {}) => ({
@@ -28,6 +28,21 @@ const card = cardFrom([
@@ -28,6 +28,21 @@ const card = cardFrom([
event({ id: 'blank', title: ' ' }),
event({ id: 'blank', title: ' ' }),
], NOW);
], NOW);
const checked = contracts.validate('frame.menu-cards@1', [card]);
const checked = contracts.validate('frame.menu-cards@1', [card]);
+
+// ── Holidays only when nothing else is on this week (11-07) ──
+(async () => {
+ const asked = [];
+ const store = (rows) => ({ listPublic: async (q) => { asked.push(q); return rows(q); } });
+ const holiday = event({ id: 'h1', title: 'Veterans Day', kind: 'holiday', starts_at: '2026-11-11T00:00:00Z' });
+ const quiet = await upcomingRows(store((q) => (q.kinds ? [holiday] : [])), NOW);
+ assert.deepStrictEqual(quiet.map((r) => r.id), ['h1'], 'a quiet week shows its holidays');
+ assert.deepStrictEqual(cardFrom(quiet, NOW).items[0].meta, 'Wed 11 Nov · Holiday', 'a holiday reads as a day, not 00:00');
+ assert.deepStrictEqual(asked.map((q) => [q.exceptKinds || null, q.kinds || null]), [[['holiday'], null], [null, ['holiday']]]);
+ asked.length = 0;
+ const busy = await upcomingRows(store((q) => (q.kinds ? [holiday] : [event()])), NOW);
+ assert.deepStrictEqual(busy.map((r) => r.id), ['ev1'], 'a real event comes first, and holidays are not asked for');
+ assert.strictEqual(asked.length, 1);
+})().catch((e) => { console.error(e); process.exit(1); });
assert.ok(checked.valid, JSON.stringify(checked.errors));
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.deepStrictEqual(Object.keys(card).sort(), ['id', 'items', 'kind', 'more', 'site', 'title', 'ttl_s', 'updated_at']);
assert.strictEqual(card.id, 'today');
assert.strictEqual(card.id, 'today');