openvibers/openvibe.live Commits

The Rundown's top shows the day: today's moments in a row, and the day in short (#754)

OP OpenVibers committed 3 days ago · f2007e951e · 3 files, +159 −2

* The Rundown's top shows the day: today's moments in a row, and the day in short

Today's moments as a row to swipe in time order (the time, who, the topic, their take, and the way to that second of
the recording), opening at the newest. Above it the AI summary's sentences, only when the owner has it switched on and
it is ready. Part of the plain front page's first draw; built with DOM nodes; nothing drawn when there is nothing.

* Trending on a phone is ranked

The place, the topic, and what the place rests on (how many streamers, how many takes), as the side box already is on
a computer. It was a row of bare chips in no stated order.

Changes

ddiff --git a/public/css/features/rundown.css b/public/css/features/rundown.css
iindex 6cb39bbb..37aaffdd 100644
---- a/public/css/features/rundown.css
++++ b/public/css/features/rundown.css
@@@ -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; }
ddiff --git a/public/js/rundown.js b/public/js/rundown.js
iindex 8e09a408..6a29a8e9 100644
---- a/public/js/rundown.js
++++ b/public/js/rundown.js
@@@ -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';
ddiff --git a/test/rundown-day-front.test.js b/test/rundown-day-front.test.js
nnew file mode 100644
iindex 00000000..d05a93e8
---- /dev/null
++++ b/test/rundown-day-front.test.js
@@@ -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');