openvibers/openvibe.today Commits

Today's card: a live stream does not hide the week's holidays; each holiday once, on its own day (#118)

OP OpenVibers committed 7 days ago · 48d1bb1f1a · 2 files, +36 −2

After #117 the card was still empty on production: a stream already live counted as 'something on', so the holidays
were never asked for, and the card leaves started events out. Now the fallback runs unless something is still to
start this week; holidays shared by many countries show once (three Armistice Days on 11 Nov); and a holiday stored
at local midnight reads as its own day (Paris's 11 Nov is 23:00 UTC on the 10th, and reads 'Wed 11 Nov').

Changes

ddiff --git a/server/menu-card.js b/server/menu-card.js
iindex ad54512..0209df8 100644
---- a/server/menu-card.js
++++ b/server/menu-card.js
@@@ -45,6 +45,24 @@ function dayLabel(iso, at) {
return new Intl.DateTimeFormat('en-GB', { timeZone: 'UTC', weekday: 'short', day: 'numeric', month: 'short' }).format(new Date(start));
}
++/**
++ * A holiday's own day, in its own zone: 11 November in Paris starts at 23:00 UTC on the 10th, and reads "Wed 11 Nov",
++ * "Tomorrow" or "Today" as it is there. An unknown zone falls back to dayLabel.
++ */
++function holidayDay(iso, tz, at) {
++ const d = new Date(iso);
++ if (!Number.isFinite(d.getTime())) return '';
++ try {
++ const zone = tz || 'UTC';
++ const key = (t) => new Intl.DateTimeFormat('en-CA', { timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(t));
++ if (key(d) === key(at)) return 'Today';
++ if (key(d) === key(at + DAY)) return 'Tomorrow';
++ return new Intl.DateTimeFormat('en-GB', { timeZone: zone, weekday: 'short', day: 'numeric', month: 'short' }).format(d);
++ } catch {
++ return dayLabel(iso, at);
++ }
++}
++
/** "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);
@@@ -91,7 +109,7 @@ function cardFrom(events, at = Date.now()) {
if (firstDay === null) firstDay = dayKey(e.starts_at);
// 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(' · ')
++ ? [holidayDay(e.starts_at, e.tz, 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;
@@@ -150,7 +168,13 @@ function createMenuCard({ load, now = Date.now } = {}) {
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'] });
++ // "Nothing else on" means nothing still to start: a stream already live is not upcoming, and the card leaves it out.
++ if (rows.some((r) => Date.parse(r.starts_at) >= at)) return rows;
++ // Many countries share a holiday's name (three Armistice Days on 11 Nov): each name once, the first by start.
++ const seen = new Set();
++ const holidays = (await events.listPublic({ ...window, kinds: ['holiday'] }))
++ .filter((h) => { const k = String(h.title || '').trim().toLowerCase(); if (!k || seen.has(k)) return false; seen.add(k); return true; });
++ return [...rows, ...holidays];
}
module.exports = { upcomingRows, 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 ee0ccb3..b0d2732 100644
---- a/test/menu-card.test.js
++++ b/test/menu-card.test.js
@@@ -42,6 +42,16 @@ const checked = contracts.validate('frame.menu-cards@1', [card]);
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);
++ // A stream already live is not upcoming: the week is still quiet, and its holidays show, each name once.
++ asked.length = 0;
++ const liveNow = event({ id: 'live', status: 'live', starts_at: '2026-11-07T11:00:00Z' });
++ const twice = event({ id: 'h2', title: 'Veterans Day', kind: 'holiday', starts_at: '2026-11-11T05:00:00Z' });
++ const quietButLive = await upcomingRows(store((q) => (q.kinds ? [holiday, twice] : [liveNow])), NOW);
++ assert.deepStrictEqual(quietButLive.map((r) => r.id), ['live', 'h1'], 'holidays come in, each name once');
++ assert.deepStrictEqual(cardFrom(quietButLive, NOW).items.map((i) => i.title), ['Veterans Day'], 'and the card shows the holiday');
++ // A holiday stored at local midnight reads as its own day: Paris's 11 Nov starts 23:00 UTC on the 10th.
++ const paris = event({ id: 'h3', title: 'Armistice Day', kind: 'holiday', tz: 'Europe/Paris', starts_at: '2026-11-10T23:00:00Z' });
++ assert.strictEqual(cardFrom([paris], NOW).items[0].meta, 'Wed 11 Nov · Holiday');
})().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']);