openvibers/openvibe.today Commits

Today: holidays get their own short section, in English; Coming up stays what people scheduled (#34)

OP OpenVibers committed 13 days ago · 925febb809 · 8 files, +96 −12

Changes

ddiff --git a/public/css/today.css b/public/css/today.css
iindex 561770d..e697fa7 100644
---- a/public/css/today.css
++++ b/public/css/today.css
@@@ -185,3 +185,13 @@ html[data-ov-motion=reduced] .td-demo-toast, html[data-ov-motion=reduced] .td-ri
.td-first-list li { display: grid; gap: 4px; padding: 14px 16px; border-radius: 16px; border: 1px dashed color-mix(in srgb, var(--room-accent) 45%, var(--border, #2c3d5c)); }
.td-first-list span { color: var(--text-secondary, #a8b3c4); font-size: 14px; line-height: 1.45; }
.td-first-list a { justify-self: start; margin-top: 4px; font-weight: 700; color: var(--room-strong, var(--room-accent)); }
++
++/* Holidays this week (10-31): one line per holiday and day, the countries that keep it, most-shared first. */
++.td-hol-days { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
++.td-hol-day { display: grid; grid-template-columns: 120px minmax(0, 1fr); gap: 12px; align-items: start; }
++.td-hol-date { font-weight: 800; color: var(--room-strong, var(--room-accent)); padding-top: 9px; }
++.td-hol-day ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
++.td-hol-day a { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; padding: 8px 12px; border-radius: 12px; background: var(--bg-card, var(--ovk-panel, #121826)); text-decoration: none; color: inherit; }
++.td-hol-day a:hover { background: var(--bg-hover, var(--ovk-panel-2, #1a2233)); }
++.td-hol-day span { color: var(--text-secondary, #a8b3c4); font-size: 14px; }
++@media (max-width: 560px) { .td-hol-day { grid-template-columns: 1fr; gap: 4px; } .td-hol-date { padding-top: 0; } }
ddiff --git a/server/events/store.js b/server/events/store.js
iindex f9ae405..4f93346 100644
---- a/server/events/store.js
++++ b/server/events/store.js
@@@ -88,12 +88,14 @@ function createEventsStore({ s, log = console }) {
return row;
}
-- async function listPublic({ channel = null, from, to, limit = 50 } = {}) {
-- const args = [from, to, Math.min(100, Math.max(1, Number(limit) || 50))];
++ async function listPublic({ channel = null, from, to, limit = 50, kinds = null, exceptKinds = null } = {}) {
++ const args = [from, to, Math.min(200, Math.max(1, Number(limit) || 50))];
// A stream that is live now stays on the public list however long ago it started; everything else is
// bounded by the window.
let where = "visibility = 'public' AND status <> 'cancelled' AND ((starts_at >= $1::timestamptz AND starts_at <= $2::timestamptz) OR (status = 'live' AND starts_at <= $2::timestamptz))";
if (channel) { args.push(channel); where += ` AND LOWER(channel) = LOWER($${args.length})`; }
++ if (Array.isArray(kinds) && kinds.length) { args.push(kinds); where += ` AND kind = ANY($${args.length}::text[])`; }
++ if (Array.isArray(exceptKinds) && exceptKinds.length) { args.push(exceptKinds); where += ` AND kind <> ALL($${args.length}::text[])`; }
const rows = await db.many(
`SELECT * FROM today_events WHERE ${where} ORDER BY starts_at ASC, id ASC LIMIT $3`,
args,
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 7300608..a8c6feb 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -72,11 +72,13 @@ function createPublicPageViews(ctx) {
async function home() {
const now = s.now();
-- const rows = await events.listPublic({ from: new Date(now - 2 * 60 * 60_000).toISOString(), to: new Date(now + 60 * DAY).toISOString(), limit: 20 });
++ const HOLIDAYS = ['holiday', 'observance'];
++ const rows = await events.listPublic({ from: new Date(now - 2 * 60 * 60_000).toISOString(), to: new Date(now + 60 * DAY).toISOString(), limit: 20, exceptKinds: HOLIDAYS });
++ const holidays = await events.listPublic({ from: new Date(now - DAY).toISOString(), to: new Date(now + 8 * DAY).toISOString(), limit: 200, kinds: ['holiday'] });
return {
index: true, cache: PUBLIC_CACHE, agent: true,
title: null,
-- body: raw(views.home({ upcoming: rows, now })),
++ body: raw(views.home({ upcoming: rows, holidays, now })),
description: 'Streams, events and reminders across OpenVibe on one calendar: schedule a stream, get reminded, and subscribe in your own calendar app.',
// Every row on the page is a dated, public event the reader can open: that is exactly one Event node.
jsonLd: oneGraph([...homeJsonLd(config), ...rows.map((e) => eventJsonLd(e, config)), seo.jsonLd.organization()]),
ddiff --git a/server/imports/holidays.js b/server/imports/holidays.js
iindex e83506c..ba3cb4c 100644
---- a/server/imports/holidays.js
++++ b/server/imports/holidays.js
@@@ -71,6 +71,12 @@ function nextDate(date) {
return new Date(Date.UTC(y, m - 1, d + 1)).toISOString().slice(0, 10);
}
++/** A country's name in English ("PL" → "Poland"); the code itself when the runtime has no name for it. */
++let regionNames = null;
++function countryName(cc) {
++ try { regionNames = regionNames || new Intl.DisplayNames(['en'], { type: 'region' }); return regionNames.of(cc) || cc; } catch { return cc; }
++}
++
/** The locale-independent key that keeps the same observance one row when its display name changes. */
function nameKey(name) {
return String(name || '')
@@@ -99,14 +105,16 @@ function recordsFor(payload, { country, year, zone = zoneOf(country) } = {}) {
const types = Array.isArray(item.types) ? item.types.map((t) => String(t).toLowerCase()) : [String(item.type || '').toLowerCase()];
const observance = types.includes('observance') && !types.includes('public');
const name = String(item.localName || item.name || 'Holiday').trim().slice(0, 200) || 'Holiday';
++ const english = String(item.name || name).trim().slice(0, 200) || name;
const cc = String(item.countryCode || countryCode).toUpperCase();
++ const where = countryName(cc);
out.push({
source: 'holidays',
source_id: 'holiday:' + cc + ':' + item.date + ':' + nameKey(name),
source_kind: observance ? 'observance' : 'holiday',
kind: observance ? 'observance' : 'holiday',
-- title: name,
-- description: (observance ? 'An observance in ' : 'A public holiday in ') + cc + ', from the Nager.Date open dataset.',
++ title: english,
++ description: (english !== name ? name + ': ' : '') + (observance ? 'an observance in ' : 'a public holiday in ') + where + ', from the Nager.Date open dataset.',
starts_at: start.toISOString(),
ends_at: end.toISOString(),
tz: zone,
ddiff --git a/server/views/index.js b/server/views/index.js
iindex 8f1458d..c6ac210 100644
---- a/server/views/index.js
++++ b/server/views/index.js
@@@ -100,7 +100,35 @@ function ring(rows, now) {
</div>`;
}
--function home({ upcoming = [], now = Date.now() } = {}) {
++/**
++ * The week's public holidays, one line per holiday and day: its English name and the countries that keep it, the ones
++ * most countries share first. The same for every visitor (the page is cached), so no country is guessed.
++ */
++function holidaysWeek(rows, now) {
++ const groups = new Map();
++ for (const e of rows) {
++ const day = dayKey(e.starts_at, e.tz);
++ const key = `${day}|${e.title}`;
++ const cc = String(e.source_id || '').split(':')[1] || '';
++ const g = groups.get(key) || { day, title: e.title, at: e.starts_at, tz: e.tz, countries: [], id: e.id };
++ if (cc && !g.countries.includes(cc)) g.countries.push(cc);
++ groups.set(key, g);
++ }
++ if (!groups.size) return '';
++ let names = null;
++ const name = (cc) => { try { names = names || new Intl.DisplayNames(['en'], { type: 'region' }); return names.of(cc) || cc; } catch { return cc; } };
++ const byDay = new Map();
++ for (const g of groups.values()) { const list = byDay.get(g.day) || []; list.push(g); byDay.set(g.day, list); }
++ const days = [...byDay.entries()].sort((a, b) => a[0].localeCompare(b[0])).slice(0, 7);
++ return `<section class="td-coming td-holidays" id="td-holidays" aria-labelledby="td-hol-h"><div class="td-head-row"><h2 id="td-hol-h">Holidays this week</h2><a class="td-more" href="https://date.nager.at" rel="noopener">From Nager.Date${kit.icon('chevron')}</a></div>
++<ol class="td-hol-days">${days.map(([, list]) => `<li class="td-hol-day"><span class="td-hol-date">${esc(dayLabel(list[0].at, list[0].tz, now))}</span><ul>${list.sort((a, b) => b.countries.length - a.countries.length || a.title.localeCompare(b.title)).map((g) => {
++ const all = g.countries.map(name).sort();
++ const shown = all.length > 4 ? `${all.slice(0, 3).join(', ')} and ${all.length - 3} more` : all.join(', ');
++ return `<li><a href="/e/${esc(g.id)}"><b>${esc(g.title)}</b><span title="${esc(all.join(', '))}">${esc(shown)}</span></a></li>`;
++ }).join('')}</ul></li>`).join('')}</ol></section>`;
++}
++
++function home({ upcoming = [], holidays = [], now = Date.now() } = {}) {
const channels = new Set(upcoming.map((e) => e.channel).filter(Boolean));
const live = upcoming.filter((e) => e.status === 'live');
const scenes = [
@@@ -152,7 +180,7 @@ ${days(upcoming, { now })}</section>` : '';
['Can I see this in my own calendar app?', 'Yes. Every channel has a public calendar feed, and your agenda has a private one. Subscribe in any app that reads iCalendar (.ics); updates and cancellations follow.'],
['Who sees what I schedule?', 'A public stream is on your channel, here and in feeds. A followers-only one is seen only by the people who follow you. Followers hear about at most three new schedules from you a day.'],
] });
-- const body = hero.replace(/<\/div>\s*$/, `${coming}${scenesHTML}${beFirst}${explore}${faq}</div>`);
++ const body = hero.replace(/<\/div>\s*$/, `${coming}${holidaysWeek(holidays, now)}${scenesHTML}${beFirst}${explore}${faq}</div>`);
return `<div class="td-wrap" id="td-home">${body}</div>`;
}
ddiff --git a/test/holidays-home.test.js b/test/holidays-home.test.js
nnew file mode 100644
iindex 0000000..8288f2d
---- /dev/null
++++ b/test/holidays-home.test.js
@@@ -0,0 +1,32 @@
++'use strict';
++// Holidays on Today's home (10-31): the import brings 25 countries' public holidays, so they get one short section of
++// their own, one line per holiday and day with the countries that keep it, most-shared first; "Coming up" stays what
++// people scheduled. A holiday is titled in English, its local name kept in the description.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const views = require('../server/views');
++const holidays = require('../server/imports/holidays');
++
++const pages = fs.readFileSync(path.join(__dirname, '..', 'server/http/pages.js'), 'utf8');
++const store = fs.readFileSync(path.join(__dirname, '..', 'server/events/store.js'), 'utf8');
++assert.match(store, /kind = ANY\(\$\$\{args\.length\}::text\[\]\)/, 'a public list can keep only some kinds');
++assert.match(store, /kind <> ALL\(\$\$\{args\.length\}::text\[\]\)/, 'or leave some out');
++assert.match(pages, /exceptKinds: HOLIDAYS/, 'the home\'s Coming up leaves holidays out');
++assert.match(pages, /kinds: \['holiday'\]/, 'and reads them on their own');
++
++const now = Date.parse('2026-10-31T12:00:00Z');
++const hol = (cc, title, date) => ({ id: `tev_${cc}${date}`, kind: 'holiday', title, starts_at: `${date}T00:00:00.000Z`, tz: 'UTC', status: 'scheduled', source_id: `holiday:${cc}:${date}:x` });
++const rows = [hol('PL', 'All Saints\' Day', '2026-11-01'), hol('FR', 'All Saints\' Day', '2026-11-01'), hol('AT', 'All Saints\' Day', '2026-11-01'), hol('BE', 'All Saints\' Day', '2026-11-01'), hol('ES', 'All Saints\' Day', '2026-11-01'), hol('US', 'Veterans Day', '2026-11-11')];
++const page = views.home({ upcoming: [], holidays: rows, now });
++assert.ok(page.includes('id="td-holidays"'), 'the holidays have their own section');
++assert.strictEqual((page.match(/<b>All Saints&#39;? ?Day<\/b>|<b>All Saints' Day<\/b>|<b>All Saints&#x27; Day<\/b>/g) || []).length, 1, 'one line for a holiday many countries keep');
++assert.match(page, /Austria, Belgium, France and 2 more/, 'the countries by name, a long list cut short');
++assert.ok(!page.includes('id="td-upcoming"'), 'no Coming up made of holidays');
++assert.ok(page.indexOf('id="td-holidays"') < page.indexOf('class="sc-room-scenes"'), 'real data before the scenes');
++
++const [pl] = holidays.recordsFor([{ date: '2026-11-01', localName: 'Wszystkich Świętych', name: 'All Saints\' Day', countryCode: 'PL', types: ['Public'] }], { country: 'PL', year: 2026 });
++assert.strictEqual(pl.title, 'All Saints\' Day', 'titled in English');
++assert.match(pl.description, /^Wszystkich Świętych: a public holiday in Poland/, 'the local name and the country in words');
++assert.match(pl.source_id, /^holiday:PL:2026-11-01:/, 'the same source id as before, so a re-run updates the row');
++console.log('today holidays: their own section, grouped, in English');
ddiff --git a/test/home-real-first.test.js b/test/home-real-first.test.js
iindex d00fac4..e1a5150 100644
---- a/test/home-real-first.test.js
++++ b/test/home-real-first.test.js
@@@ -7,7 +7,7 @@ const path = require('path');
const src = fs.readFileSync(path.join(__dirname, '..', 'server/views/index.js'), 'utf8');
assert.match(src, /const coming = upcoming\.length \? `<section class="td-coming" id="td-upcoming"/, 'Coming up only with something in it');
--assert.match(src, /\$\{coming\}\$\{scenesHTML\}\$\{beFirst\}\$\{explore\}\$\{faq\}/, 'the missing comes after the scenes');
++assert.match(src, /\$\{coming\}\$\{holidaysWeek\(holidays, now\)\}\$\{scenesHTML\}\$\{beFirst\}\$\{explore\}\$\{faq\}/, 'the real first, the missing after the scenes');
assert.ok(!src.includes('Nothing is scheduled yet'), 'no empty box under the hero');
const views = require('../server/views');
ddiff --git a/test/imports.test.js b/test/imports.test.js
iindex 8c07078..e64aa91 100644
---- a/test/imports.test.js
++++ b/test/imports.test.js
@@@ -131,18 +131,20 @@ function fakeFetch({ live = LIVE, plans = PLANS, quests = QUESTS, failLive = fal
assert.ok(!bySource('fun', 'quest:already-done'), 'completed quests are history');
assert.ok(!bySource('fun', 'quest:hidden-quest'), 'hidden quests never enter');
-- const de = events.find((e) => e.source === 'holidays' && e.title === 'Erster Weihnachtstag');
++ const de = events.find((e) => e.source === 'holidays' && String(e.source_id).startsWith('holiday:DE:2026-12-25:'));
assert.ok(de);
++ assert.strictEqual(de.title, 'Christmas Day', 'titled in English');
++ assert.match(de.description, /^Erster Weihnachtstag: a public holiday in Germany/, 'the local name and the country in words');
assert.strictEqual(de.kind, 'holiday');
assert.strictEqual(de.tz, 'Europe/Berlin');
assert.strictEqual(de.starts_at, '2026-12-24T23:00:00.000Z', 'local midnight in CET');
assert.strictEqual(de.ends_at, '2026-12-25T23:00:00.000Z');
assert.strictEqual(de.url, 'https://date.nager.at/api/v3/PublicHolidays/2026/DE');
-- const summer = events.find((e) => e.source === 'holidays' && e.title === 'Sommerfest');
++ const summer = events.find((e) => e.source === 'holidays' && String(e.source_id).startsWith('holiday:DE:2026-06-03:'));
assert.strictEqual(summer.starts_at, '2026-06-02T22:00:00.000Z', 'local midnight in CEST');
-- const us = events.find((e) => e.source === 'holidays' && e.title === 'Christmas Day');
++ const us = events.find((e) => e.source === 'holidays' && String(e.source_id).startsWith('holiday:US:2026-12-25:'));
assert.strictEqual(us.starts_at, '2026-12-25T05:00:00.000Z', 'New York midnight is 05:00 UTC');
assert.strictEqual(us.tz, 'America/New_York');
});