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)
Changes
@@@ -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 };@@@ -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']);
@@ -45,6 +45,24 @@ function dayLabel(iso, at) {
@@ -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));
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). */
/** "14:00 UTC" in the event's own zone (UTC when it has none or it is unknown). */
function timeLabel(iso, tz) {
function timeLabel(iso, tz) {
const d = new Date(iso);
const d = new Date(iso);
@@ -91,7 +109,7 @@ function cardFrom(events, at = Date.now()) {
@@ -91,7 +109,7 @@ function cardFrom(events, at = Date.now()) {
if (firstDay === null) firstDay = dayKey(e.starts_at);
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.
// 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'
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(' · ');
: [[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;
@@ -150,7 +168,13 @@ function createMenuCard({ load, now = Date.now } = {}) {
@@ -150,7 +168,13 @@ function createMenuCard({ load, now = Date.now } = {}) {
async function upcomingRows(events, at = 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 window = { from: new Date(at).toISOString(), to: new Date(at + WINDOW_DAYS * DAY).toISOString(), limit: 50 };
const rows = await events.listPublic({ ...window, exceptKinds: ['holiday'] });
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 };
module.exports = { upcomingRows, createMenuCard, cardFrom, dayLabel, timeLabel, placeOf, dayKey, startOfDay, plain, BASE, CARD_ID, SITE, CARD_TTL_S, DAY, WINDOW_DAYS, LIMIT };
@@ -42,6 +42,16 @@ const checked = contracts.validate('frame.menu-cards@1', [card]);
@@ -42,6 +42,16 @@ const checked = contracts.validate('frame.menu-cards@1', [card]);
const busy = await upcomingRows(store((q) => (q.kinds ? [holiday] : [event()])), NOW);
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.deepStrictEqual(busy.map((r) => r.id), ['ev1'], 'a real event comes first, and holidays are not asked for');
assert.strictEqual(asked.length, 1);
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); });
})().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']);