openvibers/openvibe.live Commits
Home hero: themed stat clusters + live/economy metrics
Changes
@@@ -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);@@@ -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>@@@ -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));}@@@ -7,7 +7,7 @@<!-- Keep these in step with index.html — the popout renders the same chatcomponents (cosmetics.css positions particle effects; without it they sitinline 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>@@@ -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'),},};}
@@ -2182,8 +2182,22 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
@@ -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-content { min-width: 0; max-width: min(860px, 92vw); margin: 0 auto; }
.hero-philosophy-links { margin-top: 24px; }
.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 {
.hero-stat {
display: flex; align-items: center; gap: 9px; padding: 9px 15px; border-radius: 14px;
display: flex; align-items: center; gap: 9px; padding: 9px 15px; border-radius: 14px;
background: color-mix(in srgb, var(--bg-secondary) 78%, transparent);
background: color-mix(in srgb, var(--bg-secondary) 78%, transparent);
@@ -4999,7 +4999,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -4999,7 +4999,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<!-- Scripts -->
<!-- Scripts -->
<script src="/js/theme-engine.js?v=4"></script>
<script src="/js/theme-engine.js?v=4"></script>
<script src="/live-notify.js?v=3" defer></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-player.js?v=21" defer></script>
<script src="/js/stream-pip.js?v=1" defer></script>
<script src="/js/stream-pip.js?v=1" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
@@ -1216,32 +1216,64 @@ function renderHeroStats(stats) {
@@ -1216,32 +1216,64 @@ function renderHeroStats(stats) {
const wrap = document.getElementById('hero-stats');
const wrap = document.getElementById('hero-stats');
if (!wrap || !stats) return;
if (!wrap || !stats) return;
// Short, uniform labels (full meaning in the title tooltip) so a long label never dwarfs
// 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 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
// 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.
// 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 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` : '';
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 title = (r.title || '') + recTitle(r.recent, r.unit || '');
const sub = r.sub || recSub(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>`;
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));
wrap.querySelectorAll('.hero-stat-num').forEach(el => _heroCountUp(el, parseInt(el.dataset.n, 10) || 0));
}
}
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
<!-- Keep these in step with index.html — the popout renders the same chat
<!-- Keep these in step with index.html — the popout renders the same chat
components (cosmetics.css positions particle effects; without it they sit
components (cosmetics.css positions particle effects; without it they sit
inline next to the username as "broken badges" until their cleanup timer). -->
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/chat-shared.css?v=1">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
<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">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
@@ -226,7 +226,7 @@
@@ -226,7 +226,7 @@
<!-- Chat feature scripts — mirror index.html so the popout has full parity.
<!-- Chat feature scripts — mirror index.html so the popout has full parity.
Order: dependencies (app/emotes/gif/uploads/coins/bucks/cosmetics) before chat.js. -->
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/emotes.js?v=8"></script>
<script src="/js/gif-picker.js?v=1"></script>
<script src="/js/gif-picker.js?v=1"></script>
<script src="/js/channel-uploads.js?v=12"></script>
<script src="/js/channel-uploads.js?v=12"></script>
@@ -3805,7 +3805,23 @@ function _computeHomeStats() {
@@ -3805,7 +3805,23 @@ function _computeHomeStats() {
return { d: q('-1 day'), w: q('-7 days'), m: q('-30 days') };
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);
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 {
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`),
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`),
clips: c(`SELECT COUNT(*) AS count FROM clips WHERE COALESCE(is_public, 1) = 1`),
liveSessions: c(`SELECT COUNT(*) AS count FROM streams`),
liveSessions: c(`SELECT COUNT(*) AS count FROM streams`),
@@ -3846,6 +3862,9 @@ function _computeHomeStats() {
@@ -3846,6 +3862,9 @@ function _computeHomeStats() {
aiMoments: winCount('stream_memories', 'created_at'),
aiMoments: winCount('stream_memories', 'created_at'),
messages: winCount('chat_messages', 'timestamp', 'COALESCE(is_deleted, 0) = 0'),
messages: winCount('chat_messages', 'timestamp', 'COALESCE(is_deleted, 0) = 0'),
hours: { d: hoursSince('-1 day'), w: hoursSince('-7 days'), m: hoursSince('-30 days') },
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'),
},
},
};
};
}
}