openvibers/openvibe.network Commits

On OpenVibe now: every row says when, and the order is earned (#818)

OP OpenVibers committed 2 days ago · 004af3c38f · 2 files, +137 −31

Changes

ddiff --git a/public/js/my/today.js b/public/js/my/today.js
iindex 468b1306..5cefb0e0 100644
---- a/public/js/my/today.js
++++ b/public/js/my/today.js
@@@ -141,51 +141,119 @@ function renderStatus() {
sync();
}
++// ── On OpenVibe now: the card order (owner, 2026-11-10) ──────────────────────────────────────────────────────────
++// A card is as new as its newest row and as interesting as the views, replies and viewers on its rows this week.
++// The numbers, named: freshness is 100 for a row from this hour and falls by 100/168 an hour to 0 at seven days;
++// interest is the counts the sites already print, added up and capped at 100 for a card; being looked at counts
++// twice as much as being new (×2). A live card is first whatever its numbers; a card whose newest row is older
++// than seven days goes last and says how long it has been quiet.
++const TD_FRESH_MAX = 100;
++const TD_FRESH_HOURS = 168;
++const TD_INTEREST_MAX = 100;
++const TD_INTEREST_WEIGHT = 2;
++const TD_STALE_MS = 7 * 86400000;
++const TD_AGE_UNITS = { m: 60000, min: 60000, h: 3600000, d: 86400000, w: 604800000 };
++// Cards whose own read prints a ranking and no row time. A card may also send `window`; the words are its own.
++const TD_RANKED_IDS = new Set(['trending', 'most-watched', 'popular', 'top']);
++// What a card's order means when its rows truly carry no time from their source (the source's read has none).
++const TD_ORDER_MEANING = {
++ 'new-worlds': 'newest published first',
++ trending: 'most watched this week',
++ today: 'soonest first',
++ 'hot-threads': 'newest first',
++};
++
++/** A row's own age, and its line with the time said the page's way; detail is the source's line when it has no age. */
++function tdRowTime(meta) {
++ const text = String(meta == null ? '' : meta);
++ const m = /(\d+)\s*(m|min|h|d|w)\s*ago/i.exec(text);
++ if (!m) return { ageMs: null, detail: text };
++ const ageMs = Number(m[1]) * TD_AGE_UNITS[m[2].toLowerCase()];
++ const label = tdAgo(new Date(Date.now() - ageMs).toISOString());
++ const parts = text.split(' · ');
++ const at = parts.findIndex((p) => /^\d+\s*(m|min|h|d|w)\s*ago$/i.test(p.trim()));
++ if (at >= 0) parts[at] = label;
++ else return { ageMs, detail: text.replace(m[0], label) };
++ return { ageMs, detail: parts.join(' · ') };
++}
++
++/** The counts a site already prints (views, replies, viewers, watching) on one row. */
++function tdRowCount(meta) {
++ let n = 0;
++ for (const m of String(meta == null ? '' : meta).matchAll(/([\d,]+)\s*(?:views?|replies|viewers?|watching)/gi)) n += Number(m[1].replace(/,/g, '')) || 0;
++ return n;
++}
++
++/** The person a row is by: its own field, else "by X" in its meta; '' when the source says no one. */
++function tdRowPerson(row) {
++ if (row.person || row.author) return String(row.person || row.author);
++ const m = /(?:^| · )by ([^·]+?)(?: ·|$)/i.exec(String(row.meta || ''));
++ return m ? m[1].trim() : '';
++}
++
++/**
++ * The cards in the order they have earned, with each row's age and count read from what the site printed. Rows
++ * are newest first; a ranked card keeps its own order. Every card with entries is kept here; the page shows three.
++ */
++function tdNowCards(frame) {
++ return ((frame && frame.cards) || []).filter((c) => c && Array.isArray(c.items) && c.items.some((it) => tdSafeUrl(it.url))).map((c) => {
++ const live = c.site === 'live' && /live/i.test(c.title || '');
++ const rows = c.items.filter((it) => tdSafeUrl(it.url)).map((it) => {
++ const time = tdRowTime(it.meta);
++ return { ...it, ageMs: time.ageMs, detail: time.detail, engagement: tdRowCount(it.meta) };
++ });
++ const newest = rows.reduce((min, it) => it.ageMs != null ? Math.min(min, it.ageMs) : min, Infinity);
++ const interest = Math.min(TD_INTEREST_MAX, rows.reduce((n, it) => n + it.engagement, 0));
++ // A ranking has counts but no row time ("45 views this week"), or says its window itself.
++ const ranked = !live && (Boolean(c.window) || (newest === Infinity && (interest > 0 || TD_RANKED_IDS.has(String(c.id || '')))));
++ const stale = newest !== Infinity && newest > TD_STALE_MS;
++ const fresh = newest === Infinity ? 0 : Math.max(0, TD_FRESH_MAX - (newest / 3600000) * (TD_FRESH_MAX / TD_FRESH_HOURS));
++ const rank = live ? Infinity : stale ? -1 : fresh + TD_INTEREST_WEIGHT * interest;
++ return { ...c, live, rows, newest, interest, ranked, stale, rank };
++ }).sort((a, b) => {
++ if (a.live !== b.live) return a.live ? -1 : 1;
++ if (a.stale !== b.stale) return a.stale ? 1 : -1;
++ if (a.rank !== b.rank) return b.rank - a.rank;
++ return (a.newest === Infinity ? Number.MAX_SAFE_INTEGER : a.newest) - (b.newest === Infinity ? Number.MAX_SAFE_INTEGER : b.newest);
++ });
++}
++
/**
* On OpenVibe now: what the sites themselves put forward (the menu cards, frame.menu-card@1 from /api/frame) — the
-- * week's trending videos, new worlds, who is live. Real entries only; a card with no entries stays out.
++ * week's trending videos, new worlds, who is live. Real entries only; a card with no entries stays out. The three
++ * best-earned cards are drawn; a stale card is last and short (its two newest rows and its link).
*/
function renderNet(frame) {
const box = document.getElementById('td-net');
const wrap = document.getElementById('td-net-cards');
if (!box || !wrap) return;
-- const now = Date.now();
-- // Card order: live first; otherwise age score (newer is higher) plus engagement score
-- // (views + replies + viewers, capped at 100). Rows with no relative age retain a declared window.
-- const cards = ((frame && frame.cards) || []).filter((c) => c && Array.isArray(c.items) && c.items.some((it) => tdSafeUrl(it.url))).map((c) => {
-- const live = c.site === 'live' && /live/i.test(c.title || '');
-- const rows = c.items.filter((it) => tdSafeUrl(it.url)).map((it) => {
-- const age = String(it.meta || '').match(/(\d+)\s*(m|min|h|d|w)\s*ago/i);
-- const ageMs = age ? Number(age[1]) * ({ m: 60000, min: 60000, h: 3600000, d: 86400000, w: 604800000 }[age[2].toLowerCase()]) : null;
-- const engagement = [...String(it.meta || '').matchAll(/(\d+)\s*(?:views?|replies|viewers?)/gi)].reduce((n, m) => n + Number(m[1]), 0);
-- return { ...it, ageMs, engagement };
-- });
-- const newest = rows.reduce((min, it) => it.ageMs != null ? Math.min(min, it.ageMs) : min, Infinity);
-- const score = rows.reduce((n, it) => n + Math.min(100, it.engagement), 0);
-- const rank = live ? Infinity : (newest === Infinity ? 0 : 100 - Math.min(100, newest / 3600000)) + score;
-- return { ...c, live, rows, newest, rank };
-- }).sort((a, b) => b.rank - a.rank).slice(0, 3);
++ const cards = tdNowCards(frame).slice(0, 3);
wrap.replaceChildren(...cards.map((c) => {
const site = TD_SITES[c.site] || [c.site || 'OpenVibe', 'fa-globe', null];
// One row per thing: the same title by the same person folds into one, whatever address each copy has (a
// channel's streams each have their own page: "iZoR_One's Stream" four times was one channel, 2026-11-10).
const grouped = new Map();
-- for (const row of c.rows.sort((a, b) => (a.ageMs ?? Infinity) - (b.ageMs ?? Infinity))) {
-- const key = `${String(row.title || '').trim().toLowerCase()}\u0000${row.person || row.author || ''}`;
++ for (const row of [...c.rows].sort((a, b) => (a.ageMs ?? Infinity) - (b.ageMs ?? Infinity))) {
++ const key = `${String(row.title || '').trim().toLowerCase()}\u0000${tdRowPerson(row)}`;
const old = grouped.get(key);
if (old) old.count++;
else grouped.set(key, { ...row, count: 1 });
}
-- const stale = c.newest !== Infinity && c.newest > 7 * 86400000;
-- const visibleRows = [...grouped.values()].slice(0, stale ? 2 : 5);
-- // A ranked card (no row has an age: "most watched this week") says its window once, in its head, not on every row.
-- const ranked = c.newest === Infinity;
++ const visibleRows = [...grouped.values()].slice(0, c.stale ? 2 : 5);
++ // The head's second line: the site, then what the card's order means when its rows carry no age (a ranking's
++ // window, or the order the source sends), or for a card nothing new reached in a week how long it has been quiet.
++ const meaning = c.ranked ? (c.window || 'most watched this week') : (c.newest === Infinity ? (TD_ORDER_MEANING[c.id] || "the site's own order") : null);
const streams = /^(live|media-hub|video)$/.test(String(c.site || ''));
const list = tdEl('ol', 'td-net-list', visibleRows.map((it, i) => {
const src = it.image && tdSafeUrl(it.image.src);
-- const when = ranked ? '' : String(it.meta || '').match(/\d+\s*(?:m|min|h|d|w)\s*ago/i)?.[0] || 'recently';
-- const times = it.count > 1 ? (streams ? `${it.count} streams this week` : `${it.count} times`) : '';
-- const detail = [times, when].filter(Boolean).join(' · ');
++ const times = it.count > 1 ? (c.live ? `${it.count} streams live` : streams ? `${it.count} streams this week` : `${it.count} times`) : '';
++ // Every row says when: its own age, "Live now" for a live row, or the source's own date ("Tomorrow · Holiday").
++ // A ranking's rows say the count and the head says the window once.
++ const detail = c.live
++ ? ['Live now', times || it.detail].filter(Boolean).join(' · ')
++ : c.ranked
++ ? times || String(it.detail || '').replace(/\s*this week\s*/i, ' ').trim()
++ : [times, it.detail].filter(Boolean).join(' · ');
return tdEl('li', '', [tdEl('a', 'td-net-row', [
tdEl('span', 'td-net-i', [String(i + 1)]),
src ? tdEl('img', 'td-net-thumb', null, { src, alt: '', width: 64, height: 36, loading: 'lazy', decoding: 'async' }) : null,
@@@ -194,11 +262,10 @@ function renderNet(frame) {
}));
const more = c.more && tdSafeUrl(c.more.url) ? tdEl('a', 'td-more', [String(c.more.label || 'More')], { href: tdSafeUrl(c.more.url) }) : null;
return tdEl('section', 'td-box td-net-card', [
-- // The head's second line: the site, then the ranking window, or for a card nothing new reached in a week how long
-- // it has been quiet (muted: it explains why the card is short; it is not news).
++ // The quiet line is muted: it explains why the card is short; it is not news.
tdEl('div', 'td-net-h', [tdEl('span', 'td-site-ic', [tdIcon(site[1])]), tdEl('span', 'td-net-ht', [tdEl('b', '', [String(c.title || site[0])]),
-- tdEl('small', '', [site[0], ranked ? tdEl('span', 'td-net-when', [` · ${c.window || 'most watched this week'}`]) : null,
-- stale ? tdEl('span', 'td-net-quiet', [` · quiet for ${Math.floor(c.newest / 86400000)} days`]) : null])]), more]),
++ tdEl('small', '', [site[0], meaning ? tdEl('span', 'td-net-when', [` · ${meaning}`]) : null,
++ c.stale ? tdEl('span', 'td-net-quiet', [` · quiet for ${Math.floor(c.newest / 86400000)} days`]) : null])]), more]),
list,
], { 'data-site': String(c.site || '') });
}));
ddiff --git a/test/my-today.test.js b/test/my-today.test.js
iindex d0bc41a9..82a6a707 100644
---- a/test/my-today.test.js
++++ b/test/my-today.test.js
@@@ -120,10 +120,49 @@ assert.match(CSS, /@media \(min-width:1000px\)\{[\s\S]*\.settings\{display:grid;
] });
const ordered = p.ids['td-net-cards'].children;
assert.deepStrictEqual(ordered.map((c) => c.attrs['data-site']), ['live', 'media-hub', 'community'], 'live leads, then fresh video, then stale forum');
-- assert.match(ordered[1].textContent, /1d ago/, 'a row states its age');
++ assert.match(ordered[1].textContent, /1\s*d ago/, 'a row states its age');
assert.match(ordered[2].textContent, /quiet for 18 days/, 'stale cards say why they are quiet, in the head');
assert.strictEqual(ordered[2].all((c) => c.className === 'td-net-quiet').length, 1, 'muted, beside the site');
assert.strictEqual(ordered[2].all((c) => c.tagName === 'LI').length, 1, 'stale card keeps its newest row');
++ // The owner's complaint (11-10): the forum was 18 days old and sat on top of newer, watched things. The order is
++ // earned: live first, views/replies/viewers count twice a card's freshness, and a stale card is last whatever it has.
++ const production = { cards: [
++ { id: 'hot-threads', site: 'community', title: 'New in the forum', items: [{ title: 'Old thread', url: 'https://openvibe.community/t/1', meta: 'OpenVibe · 18d ago' }] },
++ { id: 'trending', site: 'media-hub', title: 'Trending', items: [
++ { title: 'A walk', url: 'https://openvibe.video/v/1', meta: '45 views this week' },
++ { title: 'A walk', url: 'https://openvibe.video/v/2', meta: '43 views this week' },
++ ] },
++ { id: 'briefs', site: 'news', title: 'Latest AI briefs', items: [{ title: 'Today in briefs', url: 'https://openvibe.news/s/1', meta: 'AI brief · 4 reports · 1h ago' }] },
++ { id: 'new-worlds', site: 'games', title: 'New worlds', items: [{ title: 'The Workshop', url: 'https://openvibe.games/worlds/w1', meta: 'by goosely · 2d ago' }] },
++ { id: 'wiki', site: 'wiki', title: 'New on the wiki', items: [{ title: 'OpenVibe.Bot', url: 'https://openvibe.wiki/p/1', meta: 'OpenVibe · 1d ago' }] },
++ { id: 'today', site: 'today', title: 'Tomorrow', items: [{ title: 'Canterbury Anniversary Day', url: 'https://openvibe.today/e/1', meta: 'Fri 13 Nov · Holiday' }] },
++ { id: 'live', site: 'live', title: 'Live now', items: [{ title: 'Maticus', url: 'https://openvibe.live/@maticus', meta: '2 watching' }] },
++ ] };
++ const earned = p.sandbox.tdNowCards(production);
++ assert.deepStrictEqual(earned.map((c) => c.site), ['live', 'media-hub', 'news', 'wiki', 'games', 'today', 'community'], 'live, then watched and new; the 18-day forum is last');
++ assert.strictEqual(earned.find((c) => c.site === 'media-hub').ranked, true, 'a views-only card is a ranking');
++ assert.strictEqual(earned.find((c) => c.site === 'community').stale, true, '18 days is quiet');
++ assert.strictEqual(earned.find((c) => c.site === 'today').stale, false, 'a dated schedule is not stale');
++ p.sandbox.renderNet(production);
++ const top = p.ids['td-net-cards'].children;
++ assert.deepStrictEqual(top.map((c) => c.attrs['data-site']), ['live', 'media-hub', 'news'], 'the page shows the three best-earned cards');
++ assert.match(top[0].textContent, /Live now · 2 watching/, 'a live row says it is live now');
++ assert.match(top[1].textContent, /most watched this week/, 'a ranking says its window');
++ assert.match(top[2].textContent, /1 h ago/, 'a dated row says when');
++ // A source that can give its own time does: worlds carry "by X · 2d ago"; Today's rows carry their own day.
++ p.sandbox.renderNet({ cards: [{ id: 'new-worlds', site: 'games', title: 'New worlds', items: [{ title: 'The Workshop', url: 'https://openvibe.games/worlds/w1', meta: 'by goosely · 2d ago' }] }] });
++ assert.match(p.ids['td-net-cards'].textContent, /by goosely · 2 d ago/, 'a world says when it was published');
++ p.sandbox.renderNet({ cards: [{ id: 'today', site: 'today', title: 'Tomorrow', items: [{ title: 'Canterbury Anniversary Day', url: 'https://openvibe.today/e/1', meta: 'Fri 13 Nov · Holiday' }] }] });
++ assert.match(p.ids['td-net-cards'].textContent, /Fri 13 Nov · Holiday/, 'an event says its own day');
++ assert.match(p.ids['td-net-cards'].textContent, /soonest first/, 'and the card says what its order means');
++ assert.doesNotMatch(p.ids['td-net-cards'].textContent, /most watched/, 'a schedule is not called a ranking');
++ // Even many replies do not lift a card whose newest row is older than seven days above a fresh one.
++ p.sandbox.renderNet({ cards: [
++ { id: 'hot', site: 'community', title: 'New in the forum', items: [{ title: 'Old', url: 'https://openvibe.community/t/1', meta: '500 replies · 9d ago' }] },
++ { id: 'fresh', site: 'news', title: 'Latest', items: [{ title: 'New', url: 'https://openvibe.news/s/1', meta: '1h ago' }] },
++ ] });
++ assert.deepStrictEqual(p.ids['td-net-cards'].children.map((c) => c.attrs['data-site']), ['news', 'community'], 'fresh first, stale last');
++
p.sandbox.renderNet({ cards: [{ id: 'streams', site: 'live', title: 'Most watched', items: [
{ title: 'Same stream', url: 'https://openvibe.live/@a', meta: '1d ago' },
{ title: 'Same stream', url: 'https://openvibe.live/@a', meta: '2d ago' },