openvibers/openvibe.live Commits

Home hero: themed stat clusters + live/economy metrics

OP OpenVibers committed 27 Aug 2026 · 544a685674 · 5 files, +88 −23

The 15-chip stat wall becomes four labeled islands — Right now (live
streams, viewers watching, weekly active), Community (streamers, users,
anons, follows, messages, hours WATCHED), Economy (Vibes tipped with
rolling deltas, active subs, channel points earned), and Archive
(sessions, VODs, clips, hours, AI moments, emotes, pastes). New metrics:
current viewer total across live streams, community watch-time hours,
donation-ledger Vibes totals, active subscriptions, and points earned,
with day/week/month deltas for the value metrics. Empty economy chips
hide themselves so a fresh site doesn't advertise zeros.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017AyeSFu7qdLdR64Zv99QNm

Changes

ddiff --git a/public/css/style.css b/public/css/style.css
iindex fd7eb58d..0e4cfbbe 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -2182,8 +2182,22 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
.hero-content { min-width: 0; max-width: min(860px, 92vw); margin: 0 auto; }
.hero-philosophy-links { margin-top: 24px; }
--/* Stats bar */
--.hero-stats { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; margin: 4px 0 26px; }
++/* Stats bar — themed clusters (Right now / Community / Economy / Archive) instead of
++ one undifferentiated chip wall. Each group is a bordered island with a kicker. */
++.hero-stats { display: flex; flex-wrap: wrap; gap: 14px; justify-content: center; align-items: stretch; margin: 4px 0 26px; }
++.hero-stat-group {
++ display: flex; flex-direction: column; gap: 7px; align-items: center;
++ padding: 10px 12px 12px; border-radius: 16px;
++ background: color-mix(in srgb, var(--bg-primary) 34%, transparent);
++ border: 1px solid color-mix(in srgb, var(--accent) 14%, transparent);
++ backdrop-filter: blur(4px);
++}
++.hero-stat-kicker {
++ font-size: 0.62rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.14em;
++ color: color-mix(in srgb, var(--accent) 70%, var(--text-muted));
++ text-shadow: 0 1px 8px rgba(0, 0, 0, 0.5);
++}
++.hero-stat-row { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; }
.hero-stat {
display: flex; align-items: center; gap: 9px; padding: 9px 15px; border-radius: 14px;
background: color-mix(in srgb, var(--bg-secondary) 78%, transparent);
ddiff --git a/public/index.html b/public/index.html
iindex 65c4a9ad..3daff2df 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -4999,7 +4999,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<!-- Scripts -->
<script src="/js/theme-engine.js?v=4"></script>
<script src="/live-notify.js?v=3" defer></script>
--<script src="/js/app.js?v=128" defer></script>
++<script src="/js/app.js?v=129" defer></script>
<script src="/js/stream-player.js?v=21" defer></script>
<script src="/js/stream-pip.js?v=1" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
ddiff --git a/public/js/app.js b/public/js/app.js
iindex 7c1593ff..8874e3ed 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -1216,32 +1216,64 @@ function renderHeroStats(stats) {
const wrap = document.getElementById('hero-stats');
if (!wrap || !stats) return;
// Short, uniform labels (full meaning in the title tooltip) so a long label never dwarfs
-- // its number — the awkward "ACTIVE THIS WEEK" case.
++ // its number. Stats are clustered into themed groups instead of one undifferentiated
++ // 15-chip wall: what's happening NOW, the people, the economy, and the archive.
const R = stats.recent || {};
-- const rows = [];
-- if (stats.liveNow > 0) rows.push({ cls: 'hero-stat--live', icon: 'fa-circle', num: stats.liveNow, label: 'Live', title: 'Streams live right now' });
-- rows.push({ icon: 'fa-satellite-dish', num: stats.streamers, label: 'Streamers', title: 'People who have gone live' });
-- rows.push({ icon: 'fa-users', num: stats.users, label: 'Users', title: 'Registered users', recent: R.users });
-- rows.push({ icon: 'fa-fire', num: stats.weeklyActive, label: 'Active', title: 'Active this week — chatters incl. anons & relays' + (stats.weeklyVisitors ? `, plus ${stats.weeklyVisitors} new visitors` : ''), sub: stats.weeklyVisitors ? `+${_fmtCount(stats.weeklyVisitors)} new` : '' });
-- rows.push({ icon: 'fa-user-secret', num: stats.anons, label: 'Anons', title: 'Anonymous chatters ever seen', recent: R.anons });
-- rows.push({ icon: 'fa-tower-broadcast', num: stats.liveSessions, label: 'Sessions', title: 'Total stream sessions', recent: R.sessions });
-- rows.push({ icon: 'fa-film', num: stats.vods, label: 'VODs', title: 'Recorded videos', recent: R.vods });
-- rows.push({ icon: 'fa-scissors', num: stats.clips, label: 'Clips', title: 'Clips created', recent: R.clips });
-- if (stats.streamHours > 0) rows.push({ icon: 'fa-clock', num: stats.streamHours, label: 'Hours', title: 'Hours of video archived', recent: R.hours, unit: 'h' });
-- rows.push({ icon: 'fa-brain', num: stats.aiMemories, label: 'AI Moments', title: 'Moments the AI remembers across every stream', recent: R.aiMoments });
-- rows.push({ icon: 'fa-face-grin-squint', num: stats.emotes, label: 'Emotes', title: 'Custom channel emotes uploaded' });
-- rows.push({ icon: 'fa-heart', num: stats.follows, label: 'Follows', title: 'Channel follows' });
-- rows.push({ icon: 'fa-paste', num: stats.pastes, label: 'Pastes', title: `${stats.pasteText || 0} text · ${stats.pasteImages || 0} image pastes`, sub: (stats.pasteText != null && stats.pasteImages != null) ? `${_fmtCount(stats.pasteText)} txt · ${_fmtCount(stats.pasteImages)} img` : '' });
-- rows.push({ icon: 'fa-comments', num: stats.chatMessages, label: 'Messages', title: 'Chat messages sent', recent: R.messages });
++ const groups = [];
++
++ // ── Right now ────────────────────────────────────────────────
++ const now = [];
++ if (stats.liveNow > 0) now.push({ cls: 'hero-stat--live', icon: 'fa-circle', num: stats.liveNow, label: 'Live', title: 'Streams live right now' });
++ if (stats.viewersNow > 0) now.push({ cls: 'hero-stat--live', icon: 'fa-eye', num: stats.viewersNow, label: 'Watching', title: 'Viewers watching right now' });
++ now.push({ icon: 'fa-fire', num: stats.weeklyActive, label: 'Active', title: 'Active this week — chatters incl. anons & relays' + (stats.weeklyVisitors ? `, plus ${stats.weeklyVisitors} new visitors` : ''), sub: stats.weeklyVisitors ? `+${_fmtCount(stats.weeklyVisitors)} new` : '' });
++ groups.push({ kicker: 'Right now', rows: now });
++
++ // ── Community ────────────────────────────────────────────────
++ groups.push({
++ kicker: 'Community', rows: [
++ { icon: 'fa-satellite-dish', num: stats.streamers, label: 'Streamers', title: 'People who have gone live' },
++ { icon: 'fa-users', num: stats.users, label: 'Users', title: 'Registered users', recent: R.users },
++ { icon: 'fa-user-secret', num: stats.anons, label: 'Anons', title: 'Anonymous chatters ever seen', recent: R.anons },
++ { icon: 'fa-heart', num: stats.follows, label: 'Follows', title: 'Channel follows', recent: R.follows },
++ { icon: 'fa-comments', num: stats.chatMessages, label: 'Messages', title: 'Chat messages sent', recent: R.messages },
++ ...(stats.hoursWatched > 0 ? [{ icon: 'fa-couch', num: stats.hoursWatched, label: 'Hrs Watched', title: 'Hours the community has spent watching streams', unit: 'h' }] : []),
++ ],
++ });
++
++ // ── Economy ──────────────────────────────────────────────────
++ const econ = [];
++ if (stats.vibesTipped > 0) econ.push({ icon: 'fa-hand-holding-dollar', num: stats.vibesTipped, label: 'Vibes Tipped', title: 'Vibes donated between people (100 Vibes = $1)', recent: R.vibes });
++ if (stats.activeSubs > 0) econ.push({ icon: 'fa-star', num: stats.activeSubs, label: 'Subs', title: 'Active channel subscriptions' });
++ if (stats.pointsEarned > 0) econ.push({ icon: 'fa-coins', num: stats.pointsEarned, label: 'Points Earned', title: 'Channel points earned by viewers (watching, chatting, following)', recent: R.points });
++ if (econ.length) groups.push({ kicker: 'Economy', rows: econ });
++
++ // ── Archive ──────────────────────────────────────────────────
++ groups.push({
++ kicker: 'Archive', rows: [
++ { icon: 'fa-tower-broadcast', num: stats.liveSessions, label: 'Sessions', title: 'Total stream sessions', recent: R.sessions },
++ { icon: 'fa-film', num: stats.vods, label: 'VODs', title: 'Recorded videos', recent: R.vods },
++ { icon: 'fa-scissors', num: stats.clips, label: 'Clips', title: 'Clips created', recent: R.clips },
++ ...(stats.streamHours > 0 ? [{ icon: 'fa-clock', num: stats.streamHours, label: 'Hours', title: 'Hours of video archived', recent: R.hours, unit: 'h' }] : []),
++ { icon: 'fa-brain', num: stats.aiMemories, label: 'AI Moments', title: 'Moments the AI remembers across every stream', recent: R.aiMoments },
++ { icon: 'fa-face-grin-squint', num: stats.emotes, label: 'Emotes', title: 'Custom channel emotes uploaded' },
++ { icon: 'fa-paste', num: stats.pastes, label: 'Pastes', title: `${stats.pasteText || 0} text · ${stats.pasteImages || 0} image pastes`, sub: (stats.pasteText != null && stats.pasteImages != null) ? `${_fmtCount(stats.pasteText)} txt · ${_fmtCount(stats.pasteImages)} img` : '' },
++ ],
++ });
++
// For stats with rolling data: append "+d today · +w this week · +m this month" to the
// hover tooltip and show the weekly delta as the small sub-line.
const recTitle = (rec, u = '') => rec ? ` — +${_fmtCount(rec.d)}${u} today · +${_fmtCount(rec.w)}${u} this week · +${_fmtCount(rec.m)}${u} this month` : '';
const recSub = (rec, u = '') => rec ? `+${_fmtCount(rec.w)}${u} wk` : '';
-- wrap.innerHTML = rows.map(r => {
++ const chip = (r) => {
const title = (r.title || '') + recTitle(r.recent, r.unit || '');
const sub = r.sub || recSub(r.recent, r.unit || '');
return `<div class="hero-stat ${r.cls || ''}" title="${esc(title)}"><i class="fa-solid ${r.icon}"></i><div class="hero-stat-meta"><span class="hero-stat-num" data-n="${r.num || 0}">0</span><span class="hero-stat-label">${r.label}</span>${sub ? `<span class="hero-stat-sub">${sub}</span>` : ''}</div></div>`;
-- }).join('');
++ };
++ wrap.innerHTML = groups.filter(g => g.rows.length).map(g => `
++ <div class="hero-stat-group">
++ <span class="hero-stat-kicker">${g.kicker}</span>
++ <div class="hero-stat-row">${g.rows.map(chip).join('')}</div>
++ </div>`).join('');
wrap.querySelectorAll('.hero-stat-num').forEach(el => _heroCountUp(el, parseInt(el.dataset.n, 10) || 0));
}
ddiff --git a/public/popout-chat.html b/public/popout-chat.html
iindex e864ed41..45c9d2ce 100644
---- a/public/popout-chat.html
++++ b/public/popout-chat.html
@@@ -7,7 +7,7 @@
<!-- Keep these in step with index.html — the popout renders the same chat
components (cosmetics.css positions particle effects; without it they sit
inline next to the username as "broken badges" until their cleanup timer). -->
-- <link rel="stylesheet" href="/css/style.css?v=136">
++ <link rel="stylesheet" href="/css/style.css?v=137">
<link rel="stylesheet" href="/css/chat-shared.css?v=1">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
@@@ -226,7 +226,7 @@
<!-- Chat feature scripts — mirror index.html so the popout has full parity.
Order: dependencies (app/emotes/gif/uploads/coins/bucks/cosmetics) before chat.js. -->
-- <script src="/js/app.js?v=126"></script>
++ <script src="/js/app.js?v=127"></script>
<script src="/js/emotes.js?v=8"></script>
<script src="/js/gif-picker.js?v=1"></script>
<script src="/js/channel-uploads.js?v=12"></script>
ddiff --git a/server/db/database.js b/server/db/database.js
iindex b6e860c6..0e2453c1 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -3805,7 +3805,23 @@ function _computeHomeStats() {
return { d: q('-1 day'), w: q('-7 days'), m: q('-30 days') };
};
const hoursSince = (w) => Math.round(c(`SELECT COALESCE(SUM(duration_seconds), 0) AS count FROM vods WHERE COALESCE(is_recording, 0) = 0 AND created_at >= datetime('now', ?)`, [w]) / 3600);
++ // Rolling day/week/month SUMS (for value metrics like Vibes tipped).
++ const winSum = (table, col, tsCol, extra = '') => {
++ const q = (w) => c(`SELECT COALESCE(SUM(${col}), 0) AS count FROM ${table} WHERE ${tsCol} >= datetime('now', ?)${extra ? ' AND ' + extra : ''}`, [w]);
++ return { d: q('-1 day'), w: q('-7 days'), m: q('-30 days') };
++ };
return {
++ // ── Right-now + community-economy metrics ────────────────
++ // Native viewers across everything currently live.
++ viewersNow: c(`SELECT COALESCE(SUM(viewer_count), 0) AS count FROM streams WHERE is_live = 1`),
++ // Community time actually spent watching (watch-time heartbeats → hours).
++ hoursWatched: Math.round(c(`SELECT COALESCE(SUM(minutes_watched), 0) AS count FROM watch_time`) / 60),
++ // Vibes tipped between people (donation ledger; bit-style, 100 = $1).
++ vibesTipped: c(`SELECT COALESCE(SUM(amount), 0) AS count FROM transactions WHERE type = 'donation'`),
++ // Live channel subscriptions.
++ activeSubs: c(`SELECT COUNT(*) AS count FROM subscriptions WHERE status = 'active' AND (current_period_end IS NULL OR datetime(current_period_end) > CURRENT_TIMESTAMP)`),
++ // Channel points earned by viewers across every channel (watch/chat/follow bonuses).
++ pointsEarned: c(`SELECT COALESCE(SUM(amount), 0) AS count FROM coin_transactions WHERE amount > 0`),
vods: c(`SELECT COUNT(*) AS count FROM vods WHERE is_public = 1 AND COALESCE(is_recording, 0) = 0`),
clips: c(`SELECT COUNT(*) AS count FROM clips WHERE COALESCE(is_public, 1) = 1`),
liveSessions: c(`SELECT COUNT(*) AS count FROM streams`),
@@@ -3846,6 +3862,9 @@ function _computeHomeStats() {
aiMoments: winCount('stream_memories', 'created_at'),
messages: winCount('chat_messages', 'timestamp', 'COALESCE(is_deleted, 0) = 0'),
hours: { d: hoursSince('-1 day'), w: hoursSince('-7 days'), m: hoursSince('-30 days') },
++ vibes: winSum('transactions', 'amount', 'created_at', "type = 'donation'"),
++ points: winSum('coin_transactions', 'amount', 'created_at', 'amount > 0'),
++ follows: winCount('follows', 'created_at'),
},
};
}