openvibers/openvibe.live Commits
The Rundown's top shows the day: today's moments in a row, and the day in short (#754)
Changes
@@@ -545,3 +545,35 @@ body.rd-sheet-open { overflow: hidden; }#rundown-root .rd-take .rd-compact-top .rd-chip::after { content: ''; position: absolute; inset: -8px; }}@media (prefers-reduced-motion: reduce) { .rd-bar, #rundown-root .rd-react-btn, #rundown-root .rd-skeleton span { transition: none; animation: none; } }++++/* ── The day, at the top of the front page (rundown.js _rdDayDraw): the day in short when the AI summary is on, then++ today's moments as a row to swipe, in time order, each hung from one line by its dot. ── */++.rd-day { margin: 4px 0 6px; }++.rd-day .rd-h2 { margin-top: 14px; }++.rd-day-sum { margin: 0 0 12px; padding: 14px 16px; border-radius: 16px; border: 1px solid var(--border); background: radial-gradient(120% 120% at 100% 0%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 60%), var(--bg-card); }++.rd-day-sum p { margin: 0 0 6px; line-height: 1.5; }++.rd-day-sum .rd-day-sum-k { font-size: 0.74rem; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; color: color-mix(in srgb, var(--accent-light, var(--accent)) 60%, var(--text-primary)); }++.rd-day-sum .rd-day-sum-by { margin: 8px 0 0; font-size: 0.8rem; color: var(--text-muted); }++.rd-day-rail { list-style: none; margin: 0; padding: 14px 0 8px; display: grid; grid-auto-flow: column; grid-auto-columns: min(80%, 300px); gap: 12px; overflow-x: auto; scroll-snap-type: x proximity; scrollbar-width: thin; }++.rd-day-it { position: relative; scroll-snap-align: start; display: grid; align-content: start; gap: 6px; min-width: 0; padding: 14px; border-radius: 14px; border: 1px solid var(--border); background: var(--bg-card); }++.rd-day-it::after { content: ""; position: absolute; top: -8px; left: -12px; right: 0; height: 2px; background: color-mix(in srgb, var(--accent) 30%, transparent); }++.rd-day-it:first-child::after { left: 18px; }++.rd-day-it::before { content: ""; position: absolute; z-index: 1; top: -13px; left: 14px; width: 12px; height: 12px; border-radius: 50%; background: var(--bg-primary); box-shadow: inset 0 0 0 3px var(--accent); }++.rd-day-when { display: flex; align-items: baseline; gap: 8px; margin: 0; min-width: 0; font-size: 0.86rem; }++.rd-day-when time { font-weight: 800; font-variant-numeric: tabular-nums; color: color-mix(in srgb, var(--accent-light, var(--accent)) 60%, var(--text-primary)); white-space: nowrap; }++.rd-day-who { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 700; color: var(--text-primary); text-decoration: none; }++.rd-day-who:hover, .rd-day-topic:hover { text-decoration: underline; }++.rd-day-topic { justify-self: start; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 2px 10px; border-radius: 999px; font-size: 0.8rem; font-weight: 700; color: var(--text-primary); background: color-mix(in srgb, var(--accent) 16%, transparent); text-decoration: none; }++.rd-day-take { margin: 0; color: var(--text-secondary); font-size: 0.92rem; line-height: 1.45; }++.rd-day-watch { justify-self: start; display: inline-flex; align-items: center; gap: 6px; min-height: 44px; margin-top: 2px; font-weight: 700; font-size: 0.88rem; color: color-mix(in srgb, var(--accent-light, var(--accent)) 60%, var(--text-primary)); text-decoration: none; }++.rd-day-watch:hover { text-decoration: underline; }++@media (min-width: 900px) { .rd-day-rail { grid-auto-columns: 280px; } }++++/* Trending on a phone, ranked (rundown.js _rdRails): the place in the topic's own kind colour, the topic, and what the++ place rests on. */++.rd-rank { flex: none; display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 10px; max-width: 78vw; min-height: 52px; padding: 8px 14px 8px 10px; border-radius: 14px; border: 1px solid var(--border); background: var(--bg-card); color: var(--text-primary); text-decoration: none; }++.rd-rank:hover, .rd-rank:focus-visible { border-color: var(--rd-k, var(--accent)); outline: none; }++.rd-rank-n { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 50%; font-size: 0.9rem; font-weight: 800; font-variant-numeric: tabular-nums; color: color-mix(in srgb, var(--rd-k, var(--accent)) 60%, var(--text-primary)); background: color-mix(in srgb, var(--rd-k, var(--accent)) 18%, transparent); }++.rd-rank-t { display: grid; min-width: 0; }++.rd-rank-t strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.92rem; }++.rd-rank-t small { color: var(--text-muted); font-size: 0.78rem; white-space: nowrap; }@@@ -801,7 +801,14 @@ function _rdKindChips(state) {}// Trending topics and who's been talking, as two rails under the bar (on the phone) and in the side column.function _rdRails(d) {-- const trending = (d.trending || []).map(_rdTopicFilter).join('');++ // Trending on a phone is ranked, the way the side box is on a computer: the place, the topic, and what the place++ // rests on (how many streamers, how many takes). It was a row of bare chips in no stated order.++ const trending = (d.trending || []).map((t, i) => {++ const [label, color] = RD_KIND[t.kind] || RD_KIND.other;++ const meta = `${t.streamers} streamer${t.streamers === 1 ? '' : 's'} \u00b7 ${t.takes} take${t.takes === 1 ? '' : 's'}`;++ return _rdA('/rundown?topic=' + encodeURIComponent(t.slug), `<b class="rd-rank-n" aria-hidden="true">${i + 1}</b><span class="rd-rank-t"><strong>${_rdEsc(t.title)}</strong><small>${meta}</small></span>`, 'rd-rank', `${i + 1}. ${label}: ${t.title}, ${meta}`)++ .replace('class="rd-rank"', `class="rd-rank" style="--rd-k:${color}"`);++ }).join('');const voices = (d.voices || []).map((v) => '<a class="rd-rail-item" href="/rundown?streamer=' + encodeURIComponent(v.user.username) + '">' + _rdFace(v.user, 24) + '<span>' + _rdEsc(_rdName(v.user)) + '</span></a>').join('');return '<div class="rd-rails">' +'<section class="rd-rail"><div class="rd-rail-head"><h2 class="rd-h2">Trending topics</h2>' + _rdA('/rundown/topics', 'See all', 'rd-rail-all') + '</div><div class="rd-rail-track">' + (trending || '<span class="rd-quiet">No topics yet.</span>') + '</div></section>' +@@@ -887,15 +894,95 @@ function _rdBarState() {window.addEventListener('scroll', () => { update(); start(); }, { passive: true });window.addEventListener('resize', update, { passive: true });}++// ── The day, at the top of the front page (GET /api/rundown/day/today, docs/rundown.md) ──++// Today's moments in the order they happened, as a row to swipe: each a time, who, the topic and their take, with the++// way to that moment in the recording. Above it, the day in a few sentences when the owner has the AI summary switched++// on and it is ready; the card says who wrote it. Nothing is drawn when there is nothing to show (no moments yet, the++// summary off or the day too quiet). Built with DOM nodes, never innerHTML: every word here came from a stream.++async function _rdDayFetch() {++ try {++ const first = await api('/rundown/day/today?limit=100');++ const items = ((first.timeline && first.timeline.items) || []).slice();++ let next = first.timeline && first.timeline.next_offset;++ // A busy day: three more pages at most, so the row never holds more than 400 moments.++ for (let i = 0; next != null && i < 3; i++) {++ const more = await api('/rundown/day/today?limit=100&offset=' + encodeURIComponent(next));++ items.push(...((more.timeline && more.timeline.items) || []));++ next = more.timeline && more.timeline.next_offset;++ }++ return { summary: first.summary || null, items, total: (first.timeline && first.timeline.total) || items.length };++ } catch { return null; } // the front page is whole without it++}++function _rdDayEl(tag, cls, text) { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; }++function _rdDayLink(href, cls, text, spa = true) {++ const a = _rdDayEl('a', cls, text);++ a.href = href;++ if (spa) a.onclick = (e) => (typeof handleLinkClick === 'function' ? handleLinkClick(e, href) : true);++ return a;++}++function _rdDayMoment(it) {++ const li = _rdDayEl('li', 'rd-day-it');++ const when = _rdDayEl('p', 'rd-day-when');++ const t = _rdDayEl('time', '', _rdTime(it.time).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }));++ t.dateTime = _rdTime(it.time).toISOString();++ when.append(t);++ if (it.channel && it.channel.username) when.append(_rdDayLink(_rdUserHref(it.channel), 'rd-day-who', it.channel.display_name || it.channel.username));++ li.append(when);++ if (it.topic && it.topic.slug) li.append(_rdDayLink(`/rundown/t/${encodeURIComponent(it.topic.slug)}`, 'rd-day-topic', it.topic.title || it.topic.slug));++ if (it.take) li.append(_rdDayEl('p', 'rd-day-take', it.take));++ if (it.vod && it.vod.id && typeof videoHref === 'function') {++ const sec = Math.max(0, Math.floor(it.vod.sec || 0));++ const w = _rdDayLink(videoHref('vod', it.vod.id, sec), 'rd-day-watch', 'Watch this moment', false); // it leaves Live++ const stamp = _rdDayEl('span', 'rd-stamp', _rdStamp(sec));++ w.append(' ', stamp);++ li.append(w);++ }++ return li;++}++function _rdDayDraw(day) {++ const host = document.getElementById('rd-day-host');++ if (!host || !day) return;++ const sum = day.summary;++ const sentences = sum && sum.status === 'ready' ? (sum.sentences || []).filter((x) => x && x.text) : [];++ if (!day.items.length && !sentences.length) return;++ const sec = _rdDayEl('section', 'rd-day');++ sec.setAttribute('aria-labelledby', 'rd-day-h');++ const h = _rdDayEl('h2', 'rd-h2', 'Today, as it happened ');++ h.id = 'rd-day-h';++ if (day.items.length) h.append(_rdDayEl('small', '', `${day.total} moment${day.total === 1 ? '' : 's'} so far`));++ sec.append(h);++ if (sentences.length) {++ const card = _rdDayEl('div', 'rd-day-sum');++ card.append(_rdDayEl('p', 'rd-day-sum-k', 'The day in short'));++ sentences.forEach((x) => card.append(_rdDayEl('p', '', x.text)));++ card.append(_rdDayEl('p', 'rd-day-sum-by', 'Written by AI from today\u2019s takes. Each sentence rests on what a streamer said.'));++ sec.append(card);++ }++ if (day.items.length) {++ const rail = _rdDayEl('ol', 'rd-day-rail');++ rail.setAttribute('aria-label', 'Today\u2019s moments, earliest first');++ day.items.forEach((it) => rail.append(_rdDayMoment(it)));++ sec.append(rail);++ host.replaceChildren(sec);++ // The row is in time order; it opens at the newest moment, with the earlier ones a swipe back.++ rail.scrollLeft = rail.scrollWidth;++ return;++ }++ host.replaceChildren(sec);++}async function _rdBrowse(root, state) {root.innerHTML = _rdBar(state, null) + _rdSkeleton();_rdBarState();-- const d = await api('/rundown/browse' + _rdQuery(state));++ // The day's moments are part of the plain front page only (a search or a filter is about something else), and are++ // fetched beside the page's own data so they are in the first draw: nothing jumps down when they arrive.++ const plain = !state.q && !state.streamer.length && !state.topic && !state.when && !state.show && !state.video && !state.following && !state.mentions && !state.kind && state.page === 1;++ const [d, day] = await Promise.all([api('/rundown/browse' + _rdQuery(state)), plain ? _rdDayFetch() : null]);_rdRememberTopics(d.topics);_rdRememberTopics(d.trending);root.innerHTML = _rdBar(state, null) +'<div id="rd-active-host">' + _rdActiveChips(state, null) + '</div>' +_rdMast(d.counts, d.stats) + _rdHow() +++ '<div id="rd-day-host"></div>' +'<div id="rd-rails-host">' + _rdRails(d) + '</div>' +'<div class="rd-grid"><div class="rd-main">' +_rdOnAir(d.on_air || []) +@@@ -903,6 +990,7 @@ async function _rdBrowse(root, state) {'<p class="rd-count" id="rd-count">' + _rdCountLine(d, state) + '</p>' +'<div id="rd-results">' + _rdResultsHtml(d, state) + '</div>' + _rdNextLink(d, state) +'</div><aside class="rd-side">' + _rdTrending(d.trending || []) + _rdVoices(d.voices || []) + _rdAbout() + '</aside></div>';++ _rdDayDraw(day);_rdBarState();_rdObserveMore();document.title = (state.q ? 'Search: ' + state.q + ' · ' : '') + 'The Rundown · OpenVibe.Live';nnew file mode 100644@@@ -0,0 +1,37 @@++'use strict';++// The Rundown's front page draws the day (GET /api/rundown/day/today): today's moments as a row in time order, and the++// day in short when the owner has the AI summary on. Rules this file holds: the section is part of the plain front++// page only, it is in the first draw (fetched beside the page's own data), it is built with DOM nodes (every word in++// it came from a stream), and nothing is drawn when there is nothing to show.++const assert = require('assert');++const fs = require('fs');++const path = require('path');++++const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'rundown.js'), 'utf8');++const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'features', 'rundown.css'), 'utf8');++++const from = js.indexOf('async function _rdDayFetch()');++const to = js.indexOf('async function _rdBrowse(root, state) {');++assert.ok(from > 0 && to > from, 'the day section sits before the front page that uses it');++const day = js.slice(from, to);++++assert.ok(day.includes("api('/rundown/day/today?limit=100')"), 'it reads the day endpoint');++assert.ok(!/innerHTML|insertAdjacentHTML|outerHTML/.test(day), 'the day section is built with DOM nodes, never markup strings');++assert.ok(day.includes("sum.status === 'ready'"), 'the day in short shows only when the summary is ready (off, too few takes: no card)');++assert.ok(day.includes('if (!day.items.length && !sentences.length) return;'), 'nothing to show: nothing is drawn');++assert.ok(day.includes("'Written by AI from today\\u2019s takes."), 'the summary says who wrote it');++assert.ok(day.includes('rail.scrollLeft = rail.scrollWidth;'), 'the row opens at the newest moment');++assert.ok(/for \(let i = 0; next != null && i < 3; i\+\+\)/.test(day), 'a busy day is read in a bounded number of pages');++++const browse = js.slice(to, js.indexOf('async function _rdRefresh()'));++assert.ok(browse.includes('const [d, day] = await Promise.all([api(\'/rundown/browse\' + _rdQuery(state)), plain ? _rdDayFetch() : null]);'), 'the day is fetched beside the page, so it is in the first draw');++assert.ok(/const plain = !state\.q && !state\.streamer\.length && !state\.topic/.test(browse), 'a search or a filter does not show the day');++assert.ok(browse.includes('<div id="rd-day-host"></div>') && browse.includes('_rdDayDraw(day);'));++++for (const sel of ['.rd-day-rail', '.rd-day-it', '.rd-day-when', '.rd-day-topic', '.rd-day-take', '.rd-day-watch', '.rd-day-sum']) assert.ok(css.includes(`${sel} {`), `${sel} is styled`);++assert.ok(/\.rd-day-watch \{[^}]*min-height: 44px/.test(css), 'the way to the moment is a full touch target');++// Trending on a phone is ranked: the place, the topic, and what the place rests on.++const rails = js.slice(js.indexOf('function _rdRails(d) {'), js.indexOf('// ── The page the bar is on'));++assert.ok(rails.includes('<b class="rd-rank-n" aria-hidden="true">${i + 1}</b>') && rails.includes("${t.streamers} streamer") && rails.includes("${t.takes} take"), 'a ranked row says its place and what it rests on');++assert.ok(css.includes('.rd-rank {') && /\.rd-rank \{[^}]*min-height: 52px/.test(css), 'and is a full touch target');++console.log('rundown day on the front page: OK');
@@ -545,3 +545,35 @@ body.rd-sheet-open { overflow: hidden; }
@@ -545,3 +545,35 @@ body.rd-sheet-open { overflow: hidden; }
#rundown-root .rd-take .rd-compact-top .rd-chip::after { content: ''; position: absolute; inset: -8px; }
#rundown-root .rd-take .rd-compact-top .rd-chip::after { content: ''; position: absolute; inset: -8px; }
}
}
@media (prefers-reduced-motion: reduce) { .rd-bar, #rundown-root .rd-react-btn, #rundown-root .rd-skeleton span { transition: none; animation: none; } }
@media (prefers-reduced-motion: reduce) { .rd-bar, #rundown-root .rd-react-btn, #rundown-root .rd-skeleton span { transition: none; animation: none; } }
+
+/* ── The day, at the top of the front page (rundown.js _rdDayDraw): the day in short when the AI summary is on, then
+ today's moments as a row to swipe, in time order, each hung from one line by its dot. ── */
+.rd-day { margin: 4px 0 6px; }
+.rd-day .rd-h2 { margin-top: 14px; }
+.rd-day-sum { margin: 0 0 12px; padding: 14px 16px; border-radius: 16px; border: 1px solid var(--border); background: radial-gradient(120% 120% at 100% 0%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 60%), var(--bg-card); }
+.rd-day-sum p { margin: 0 0 6px; line-height: 1.5; }
+.rd-day-sum .rd-day-sum-k { font-size: 0.74rem; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; color: color-mix(in srgb, var(--accent-light, var(--accent)) 60%, var(--text-primary)); }
+.rd-day-sum .rd-day-sum-by { margin: 8px 0 0; font-size: 0.8rem; color: var(--text-muted); }
+.rd-day-rail { list-style: none; margin: 0; padding: 14px 0 8px; display: grid; grid-auto-flow: column; grid-auto-columns: min(80%, 300px); gap: 12px; overflow-x: auto; scroll-snap-type: x proximity; scrollbar-width: thin; }
+.rd-day-it { position: relative; scroll-snap-align: start; display: grid; align-content: start; gap: 6px; min-width: 0; padding: 14px; border-radius: 14px; border: 1px solid var(--border); background: var(--bg-card); }
+.rd-day-it::after { content: ""; position: absolute; top: -8px; left: -12px; right: 0; height: 2px; background: color-mix(in srgb, var(--accent) 30%, transparent); }
+.rd-day-it:first-child::after { left: 18px; }
+.rd-day-it::before { content: ""; position: absolute; z-index: 1; top: -13px; left: 14px; width: 12px; height: 12px; border-radius: 50%; background: var(--bg-primary); box-shadow: inset 0 0 0 3px var(--accent); }
+.rd-day-when { display: flex; align-items: baseline; gap: 8px; margin: 0; min-width: 0; font-size: 0.86rem; }
+.rd-day-when time { font-weight: 800; font-variant-numeric: tabular-nums; color: color-mix(in srgb, var(--accent-light, var(--accent)) 60%, var(--text-primary)); white-space: nowrap; }
+.rd-day-who { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 700; color: var(--text-primary); text-decoration: none; }
+.rd-day-who:hover, .rd-day-topic:hover { text-decoration: underline; }
+.rd-day-topic { justify-self: start; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 2px 10px; border-radius: 999px; font-size: 0.8rem; font-weight: 700; color: var(--text-primary); background: color-mix(in srgb, var(--accent) 16%, transparent); text-decoration: none; }
+.rd-day-take { margin: 0; color: var(--text-secondary); font-size: 0.92rem; line-height: 1.45; }
+.rd-day-watch { justify-self: start; display: inline-flex; align-items: center; gap: 6px; min-height: 44px; margin-top: 2px; font-weight: 700; font-size: 0.88rem; color: color-mix(in srgb, var(--accent-light, var(--accent)) 60%, var(--text-primary)); text-decoration: none; }
+.rd-day-watch:hover { text-decoration: underline; }
+@media (min-width: 900px) { .rd-day-rail { grid-auto-columns: 280px; } }
+
+/* Trending on a phone, ranked (rundown.js _rdRails): the place in the topic's own kind colour, the topic, and what the
+ place rests on. */
+.rd-rank { flex: none; display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 10px; max-width: 78vw; min-height: 52px; padding: 8px 14px 8px 10px; border-radius: 14px; border: 1px solid var(--border); background: var(--bg-card); color: var(--text-primary); text-decoration: none; }
+.rd-rank:hover, .rd-rank:focus-visible { border-color: var(--rd-k, var(--accent)); outline: none; }
+.rd-rank-n { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 50%; font-size: 0.9rem; font-weight: 800; font-variant-numeric: tabular-nums; color: color-mix(in srgb, var(--rd-k, var(--accent)) 60%, var(--text-primary)); background: color-mix(in srgb, var(--rd-k, var(--accent)) 18%, transparent); }
+.rd-rank-t { display: grid; min-width: 0; }
+.rd-rank-t strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.92rem; }
+.rd-rank-t small { color: var(--text-muted); font-size: 0.78rem; white-space: nowrap; }
@@ -801,7 +801,14 @@ function _rdKindChips(state) {
@@ -801,7 +801,14 @@ function _rdKindChips(state) {
}
}
// Trending topics and who's been talking, as two rails under the bar (on the phone) and in the side column.
// Trending topics and who's been talking, as two rails under the bar (on the phone) and in the side column.
function _rdRails(d) {
function _rdRails(d) {
- const trending = (d.trending || []).map(_rdTopicFilter).join('');
+ // Trending on a phone is ranked, the way the side box is on a computer: the place, the topic, and what the place
+ // rests on (how many streamers, how many takes). It was a row of bare chips in no stated order.
+ const trending = (d.trending || []).map((t, i) => {
+ const [label, color] = RD_KIND[t.kind] || RD_KIND.other;
+ const meta = `${t.streamers} streamer${t.streamers === 1 ? '' : 's'} \u00b7 ${t.takes} take${t.takes === 1 ? '' : 's'}`;
+ return _rdA('/rundown?topic=' + encodeURIComponent(t.slug), `<b class="rd-rank-n" aria-hidden="true">${i + 1}</b><span class="rd-rank-t"><strong>${_rdEsc(t.title)}</strong><small>${meta}</small></span>`, 'rd-rank', `${i + 1}. ${label}: ${t.title}, ${meta}`)
+ .replace('class="rd-rank"', `class="rd-rank" style="--rd-k:${color}"`);
+ }).join('');
const voices = (d.voices || []).map((v) => '<a class="rd-rail-item" href="/rundown?streamer=' + encodeURIComponent(v.user.username) + '">' + _rdFace(v.user, 24) + '<span>' + _rdEsc(_rdName(v.user)) + '</span></a>').join('');
const voices = (d.voices || []).map((v) => '<a class="rd-rail-item" href="/rundown?streamer=' + encodeURIComponent(v.user.username) + '">' + _rdFace(v.user, 24) + '<span>' + _rdEsc(_rdName(v.user)) + '</span></a>').join('');
return '<div class="rd-rails">' +
return '<div class="rd-rails">' +
'<section class="rd-rail"><div class="rd-rail-head"><h2 class="rd-h2">Trending topics</h2>' + _rdA('/rundown/topics', 'See all', 'rd-rail-all') + '</div><div class="rd-rail-track">' + (trending || '<span class="rd-quiet">No topics yet.</span>') + '</div></section>' +
'<section class="rd-rail"><div class="rd-rail-head"><h2 class="rd-h2">Trending topics</h2>' + _rdA('/rundown/topics', 'See all', 'rd-rail-all') + '</div><div class="rd-rail-track">' + (trending || '<span class="rd-quiet">No topics yet.</span>') + '</div></section>' +
@@ -887,15 +894,95 @@ function _rdBarState() {
@@ -887,15 +894,95 @@ function _rdBarState() {
window.addEventListener('scroll', () => { update(); start(); }, { passive: true });
window.addEventListener('scroll', () => { update(); start(); }, { passive: true });
window.addEventListener('resize', update, { passive: true });
window.addEventListener('resize', update, { passive: true });
}
}
+// ── The day, at the top of the front page (GET /api/rundown/day/today, docs/rundown.md) ──
+// Today's moments in the order they happened, as a row to swipe: each a time, who, the topic and their take, with the
+// way to that moment in the recording. Above it, the day in a few sentences when the owner has the AI summary switched
+// on and it is ready; the card says who wrote it. Nothing is drawn when there is nothing to show (no moments yet, the
+// summary off or the day too quiet). Built with DOM nodes, never innerHTML: every word here came from a stream.
+async function _rdDayFetch() {
+ try {
+ const first = await api('/rundown/day/today?limit=100');
+ const items = ((first.timeline && first.timeline.items) || []).slice();
+ let next = first.timeline && first.timeline.next_offset;
+ // A busy day: three more pages at most, so the row never holds more than 400 moments.
+ for (let i = 0; next != null && i < 3; i++) {
+ const more = await api('/rundown/day/today?limit=100&offset=' + encodeURIComponent(next));
+ items.push(...((more.timeline && more.timeline.items) || []));
+ next = more.timeline && more.timeline.next_offset;
+ }
+ return { summary: first.summary || null, items, total: (first.timeline && first.timeline.total) || items.length };
+ } catch { return null; } // the front page is whole without it
+}
+function _rdDayEl(tag, cls, text) { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; }
+function _rdDayLink(href, cls, text, spa = true) {
+ const a = _rdDayEl('a', cls, text);
+ a.href = href;
+ if (spa) a.onclick = (e) => (typeof handleLinkClick === 'function' ? handleLinkClick(e, href) : true);
+ return a;
+}
+function _rdDayMoment(it) {
+ const li = _rdDayEl('li', 'rd-day-it');
+ const when = _rdDayEl('p', 'rd-day-when');
+ const t = _rdDayEl('time', '', _rdTime(it.time).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }));
+ t.dateTime = _rdTime(it.time).toISOString();
+ when.append(t);
+ if (it.channel && it.channel.username) when.append(_rdDayLink(_rdUserHref(it.channel), 'rd-day-who', it.channel.display_name || it.channel.username));
+ li.append(when);
+ if (it.topic && it.topic.slug) li.append(_rdDayLink(`/rundown/t/${encodeURIComponent(it.topic.slug)}`, 'rd-day-topic', it.topic.title || it.topic.slug));
+ if (it.take) li.append(_rdDayEl('p', 'rd-day-take', it.take));
+ if (it.vod && it.vod.id && typeof videoHref === 'function') {
+ const sec = Math.max(0, Math.floor(it.vod.sec || 0));
+ const w = _rdDayLink(videoHref('vod', it.vod.id, sec), 'rd-day-watch', 'Watch this moment', false); // it leaves Live
+ const stamp = _rdDayEl('span', 'rd-stamp', _rdStamp(sec));
+ w.append(' ', stamp);
+ li.append(w);
+ }
+ return li;
+}
+function _rdDayDraw(day) {
+ const host = document.getElementById('rd-day-host');
+ if (!host || !day) return;
+ const sum = day.summary;
+ const sentences = sum && sum.status === 'ready' ? (sum.sentences || []).filter((x) => x && x.text) : [];
+ if (!day.items.length && !sentences.length) return;
+ const sec = _rdDayEl('section', 'rd-day');
+ sec.setAttribute('aria-labelledby', 'rd-day-h');
+ const h = _rdDayEl('h2', 'rd-h2', 'Today, as it happened ');
+ h.id = 'rd-day-h';
+ if (day.items.length) h.append(_rdDayEl('small', '', `${day.total} moment${day.total === 1 ? '' : 's'} so far`));
+ sec.append(h);
+ if (sentences.length) {
+ const card = _rdDayEl('div', 'rd-day-sum');
+ card.append(_rdDayEl('p', 'rd-day-sum-k', 'The day in short'));
+ sentences.forEach((x) => card.append(_rdDayEl('p', '', x.text)));
+ card.append(_rdDayEl('p', 'rd-day-sum-by', 'Written by AI from today\u2019s takes. Each sentence rests on what a streamer said.'));
+ sec.append(card);
+ }
+ if (day.items.length) {
+ const rail = _rdDayEl('ol', 'rd-day-rail');
+ rail.setAttribute('aria-label', 'Today\u2019s moments, earliest first');
+ day.items.forEach((it) => rail.append(_rdDayMoment(it)));
+ sec.append(rail);
+ host.replaceChildren(sec);
+ // The row is in time order; it opens at the newest moment, with the earlier ones a swipe back.
+ rail.scrollLeft = rail.scrollWidth;
+ return;
+ }
+ host.replaceChildren(sec);
+}
async function _rdBrowse(root, state) {
async function _rdBrowse(root, state) {
root.innerHTML = _rdBar(state, null) + _rdSkeleton();
root.innerHTML = _rdBar(state, null) + _rdSkeleton();
_rdBarState();
_rdBarState();
- const d = await api('/rundown/browse' + _rdQuery(state));
+ // The day's moments are part of the plain front page only (a search or a filter is about something else), and are
+ // fetched beside the page's own data so they are in the first draw: nothing jumps down when they arrive.
+ const plain = !state.q && !state.streamer.length && !state.topic && !state.when && !state.show && !state.video && !state.following && !state.mentions && !state.kind && state.page === 1;
+ const [d, day] = await Promise.all([api('/rundown/browse' + _rdQuery(state)), plain ? _rdDayFetch() : null]);
_rdRememberTopics(d.topics);
_rdRememberTopics(d.topics);
_rdRememberTopics(d.trending);
_rdRememberTopics(d.trending);
root.innerHTML = _rdBar(state, null) +
root.innerHTML = _rdBar(state, null) +
'<div id="rd-active-host">' + _rdActiveChips(state, null) + '</div>' +
'<div id="rd-active-host">' + _rdActiveChips(state, null) + '</div>' +
_rdMast(d.counts, d.stats) + _rdHow() +
_rdMast(d.counts, d.stats) + _rdHow() +
+ '<div id="rd-day-host"></div>' +
'<div id="rd-rails-host">' + _rdRails(d) + '</div>' +
'<div id="rd-rails-host">' + _rdRails(d) + '</div>' +
'<div class="rd-grid"><div class="rd-main">' +
'<div class="rd-grid"><div class="rd-main">' +
_rdOnAir(d.on_air || []) +
_rdOnAir(d.on_air || []) +
@@ -903,6 +990,7 @@ async function _rdBrowse(root, state) {
@@ -903,6 +990,7 @@ async function _rdBrowse(root, state) {
'<p class="rd-count" id="rd-count">' + _rdCountLine(d, state) + '</p>' +
'<p class="rd-count" id="rd-count">' + _rdCountLine(d, state) + '</p>' +
'<div id="rd-results">' + _rdResultsHtml(d, state) + '</div>' + _rdNextLink(d, state) +
'<div id="rd-results">' + _rdResultsHtml(d, state) + '</div>' + _rdNextLink(d, state) +
'</div><aside class="rd-side">' + _rdTrending(d.trending || []) + _rdVoices(d.voices || []) + _rdAbout() + '</aside></div>';
'</div><aside class="rd-side">' + _rdTrending(d.trending || []) + _rdVoices(d.voices || []) + _rdAbout() + '</aside></div>';
+ _rdDayDraw(day);
_rdBarState();
_rdBarState();
_rdObserveMore();
_rdObserveMore();
document.title = (state.q ? 'Search: ' + state.q + ' · ' : '') + 'The Rundown · OpenVibe.Live';
document.title = (state.q ? 'Search: ' + state.q + ' · ' : '') + 'The Rundown · OpenVibe.Live';
new file mode 100644
new file mode 100644
@@ -0,0 +1,37 @@
@@ -0,0 +1,37 @@
+'use strict';
+// The Rundown's front page draws the day (GET /api/rundown/day/today): today's moments as a row in time order, and the
+// day in short when the owner has the AI summary on. Rules this file holds: the section is part of the plain front
+// page only, it is in the first draw (fetched beside the page's own data), it is built with DOM nodes (every word in
+// it came from a stream), and nothing is drawn when there is nothing to show.
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'rundown.js'), 'utf8');
+const css = fs.readFileSync(path.join(__dirname, '..', 'public', 'css', 'features', 'rundown.css'), 'utf8');
+
+const from = js.indexOf('async function _rdDayFetch()');
+const to = js.indexOf('async function _rdBrowse(root, state) {');
+assert.ok(from > 0 && to > from, 'the day section sits before the front page that uses it');
+const day = js.slice(from, to);
+
+assert.ok(day.includes("api('/rundown/day/today?limit=100')"), 'it reads the day endpoint');
+assert.ok(!/innerHTML|insertAdjacentHTML|outerHTML/.test(day), 'the day section is built with DOM nodes, never markup strings');
+assert.ok(day.includes("sum.status === 'ready'"), 'the day in short shows only when the summary is ready (off, too few takes: no card)');
+assert.ok(day.includes('if (!day.items.length && !sentences.length) return;'), 'nothing to show: nothing is drawn');
+assert.ok(day.includes("'Written by AI from today\\u2019s takes."), 'the summary says who wrote it');
+assert.ok(day.includes('rail.scrollLeft = rail.scrollWidth;'), 'the row opens at the newest moment');
+assert.ok(/for \(let i = 0; next != null && i < 3; i\+\+\)/.test(day), 'a busy day is read in a bounded number of pages');
+
+const browse = js.slice(to, js.indexOf('async function _rdRefresh()'));
+assert.ok(browse.includes('const [d, day] = await Promise.all([api(\'/rundown/browse\' + _rdQuery(state)), plain ? _rdDayFetch() : null]);'), 'the day is fetched beside the page, so it is in the first draw');
+assert.ok(/const plain = !state\.q && !state\.streamer\.length && !state\.topic/.test(browse), 'a search or a filter does not show the day');
+assert.ok(browse.includes('<div id="rd-day-host"></div>') && browse.includes('_rdDayDraw(day);'));
+
+for (const sel of ['.rd-day-rail', '.rd-day-it', '.rd-day-when', '.rd-day-topic', '.rd-day-take', '.rd-day-watch', '.rd-day-sum']) assert.ok(css.includes(`${sel} {`), `${sel} is styled`);
+assert.ok(/\.rd-day-watch \{[^}]*min-height: 44px/.test(css), 'the way to the moment is a full touch target');
+// Trending on a phone is ranked: the place, the topic, and what the place rests on.
+const rails = js.slice(js.indexOf('function _rdRails(d) {'), js.indexOf('// ── The page the bar is on'));
+assert.ok(rails.includes('<b class="rd-rank-n" aria-hidden="true">${i + 1}</b>') && rails.includes("${t.streamers} streamer") && rails.includes("${t.takes} take"), 'a ranked row says its place and what it rests on');
+assert.ok(css.includes('.rd-rank {') && /\.rd-rank \{[^}]*min-height: 52px/.test(css), 'and is a full touch target');
+console.log('rundown day on the front page: OK');