openvibers/openvibe.live Commits
Home stats: tooltips, click-through charts, clearer windows; AI timeline: persistent moment frames + speech/sound layout; Vibes reset
Changes
@@@ -14104,3 +14104,82 @@ label.staff-inline-toggle.aiv-field { flex-direction:row; align-items:center; }}#arena-stat-detail { grid-column: 1 / -1; }#arena-stat-detail:empty { display: none; }++++/* ── AI timeline transcript: speech lines vs. sound strips ── */++.ai-tl-tr-bar { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; margin: 0 0 8px; padding-bottom: 8px; border-bottom: 1px solid var(--border, rgba(255,255,255,0.07)); }++.ai-tl-tr-filter { display: inline-flex; gap: 4px; }++.ai-tl-tr-filter button { padding: 4px 10px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,0.1)); background: transparent; color: var(--text-secondary); font-size: 0.75rem; cursor: pointer; display: inline-flex; align-items: center; gap: 5px; }++.ai-tl-tr-filter button span { font-size: 0.68rem; color: var(--text-muted); }++.ai-tl-tr-filter button.active { background: color-mix(in srgb, var(--accent, #8b5cf6) 18%, transparent); border-color: var(--accent, #8b5cf6); color: var(--text-primary); }++.ai-tl-tr-hint { font-size: 0.72rem; color: var(--text-muted); }++.ai-tl-tr-speech .ai-tl-tr-text { color: var(--text-primary); }++.ai-tl-tr-sounds { align-items: center; padding: 2px 0; }++.ai-tl-ts--sound { color: var(--text-muted); font-weight: 600; }++.ai-tl-sound-strip { display: flex; flex-wrap: wrap; gap: 4px; }++.ai-tl-sound { display: inline-flex; align-items: center; gap: 5px; padding: 2px 8px; border-radius: 999px; font-size: 0.7rem; color: var(--text-secondary); background: var(--bg-elevated, rgba(255,255,255,0.04)); border: 1px solid var(--border, rgba(255,255,255,0.07)); }++.ai-tl-sound i { font-size: 0.68rem; opacity: 0.8; }++.ai-tl-sound b { font-weight: 700; color: var(--text-muted); margin-left: 1px; }++.ai-tl-sound--rain, .ai-tl-sound--thunder, .ai-tl-sound--wind { color: #7cc4ff; }++.ai-tl-sound--music { color: #d8a0ff; }++.ai-tl-sound--laugh, .ai-tl-sound--crowd { color: #ffd166; }++.ai-tl-sound--explosion, .ai-tl-sound--alarm { color: #ff8b6b; }++.ai-tl-sound--vehicle, .ai-tl-sound--tools, .ai-tl-sound--keys, .ai-tl-sound--door { color: #b8c4d0; }++.ai-tl-sound--animal, .ai-tl-sound--kitchen { color: #9be7a6; }++.ai-tl-transcript[data-filter="speech"] .ai-tl-tr-sounds { display: none; }++.ai-tl-transcript[data-filter="sounds"] .ai-tl-tr-speech { display: none; }++++/* ── Hero stats: tooltips, click-through charts, mobile ── */++.hero-stat--clickable { cursor: pointer; }++.hero-stat--clickable:hover, .hero-stat--clickable:focus-visible { transform: translateY(-1px); border-color: color-mix(in srgb, var(--accent) 55%, transparent); box-shadow: 0 6px 18px rgba(0, 0, 0, 0.25); outline: none; }++.hero-stat--clickable:active { transform: translateY(0) scale(0.99); }++.hero-stat-chart-hint { font-size: 0.6em; opacity: 0; margin-left: 4px; transition: opacity 0.15s; }++.hero-stat--clickable:hover .hero-stat-chart-hint { opacity: 0.8; }++.hero-tip { position: fixed; z-index: 4000; max-width: 300px; padding: 10px 12px; border-radius: 12px; background: var(--bg-card, #1a1a20); border: 1px solid var(--border-light, #3a3a44); box-shadow: 0 12px 34px rgba(0, 0, 0, 0.5); color: var(--text-primary); font-size: 0.8rem; line-height: 1.45; pointer-events: none; opacity: 0; transform: translateY(4px); transition: opacity 0.12s, transform 0.12s; }++.hero-tip.is-visible { opacity: 1; transform: none; }++.hero-tip-title { font-weight: 700; margin-bottom: 2px; }++.hero-tip-desc { color: var(--text-secondary); }++.hero-tip-recent { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin-top: 8px; }++.hero-tip-recent div { background: var(--bg-tertiary); border-radius: 8px; padding: 5px 7px; text-align: center; }++.hero-tip-recent b { display: block; font-size: 0.9rem; color: var(--accent-light, #a78bfa); }++.hero-tip-recent span { font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); }++.hero-tip-cta { margin-top: 8px; font-size: 0.7rem; color: var(--text-muted); }++.hero-chart-overlay { position: fixed; inset: 0; z-index: 4100; background: rgba(0, 0, 0, 0.7); display: flex; align-items: center; justify-content: center; padding: 20px; animation: heroFadeIn 0.18s ease-out; }++@keyframes heroFadeIn { from { opacity: 0; } to { opacity: 1; } }++.hero-chart { width: min(760px, 100%); background: var(--bg-card); border: 1px solid var(--border); border-radius: 18px; padding: 18px 20px 16px; animation: heroPop 0.22s cubic-bezier(0.2, 0.9, 0.3, 1.2); }++@keyframes heroPop { from { transform: scale(0.96); opacity: 0; } to { transform: none; opacity: 1; } }++.hero-chart-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 6px; }++.hero-chart-head h3 { margin: 0; font-size: 1.15rem; display: flex; align-items: center; gap: 8px; }++.hero-chart-head h3 i { color: var(--accent); }++.hero-chart-close { background: none; border: 0; color: var(--text-muted); font-size: 1.5rem; cursor: pointer; line-height: 1; }++.hero-chart-desc { color: var(--text-secondary); font-size: 0.86rem; margin: 0 0 10px; }++.hero-chart-ranges { display: inline-flex; gap: 4px; margin-bottom: 10px; }++.hero-chart-ranges button { padding: 4px 10px; border-radius: 999px; border: 1px solid var(--border); background: transparent; color: var(--text-secondary); font-size: 0.75rem; cursor: pointer; }++.hero-chart-ranges button.active { background: color-mix(in srgb, var(--accent) 18%, transparent); border-color: var(--accent); color: var(--text-primary); }++.hero-chart-svg { width: 100%; height: 220px; display: block; }++.hero-chart-svg .bar { fill: var(--accent); opacity: 0.85; transition: opacity 0.1s; transform-origin: bottom; animation: heroBarGrow 0.5s cubic-bezier(0.2, 0.8, 0.3, 1) both; }++.hero-chart-svg .bar:hover { opacity: 1; }++@keyframes heroBarGrow { from { transform: scaleY(0); } to { transform: scaleY(1); } }++.hero-chart-svg .grid { stroke: var(--border); stroke-width: 1; }++.hero-chart-svg text { fill: var(--text-muted); font-size: 10px; }++.hero-chart-svg .line { fill: none; stroke: var(--accent-light); stroke-width: 2; stroke-linejoin: round; }++.hero-chart-svg .area { fill: color-mix(in srgb, var(--accent) 22%, transparent); }++.hero-chart-foot { display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap; font-size: 0.78rem; color: var(--text-muted); margin-top: 6px; }++.hero-chart-foot b { color: var(--text-primary); }++.hero-chart-loading { padding: 60px 0; text-align: center; color: var(--text-muted); }++@media (max-width: 720px) {++ /* Icons back on phones — smaller, and the label wraps instead of hiding them. */++ .hero-stat > i { display: inline-block !important; font-size: 0.85rem; }++ .hero-stat { padding: 7px 8px; gap: 6px; }++ .hero-stat-label { white-space: normal; line-height: 1.15; }++ .hero-stat-chart-hint { opacity: 0.6; }++ .hero-tip { max-width: calc(100vw - 24px); }++ .hero-chart { padding: 14px; }++ .hero-chart-svg { height: 170px; }++ /* Scroll performance: no hover transforms/shadows and no blur on phones — a blurred++ fixed nav over an animated hero is the most expensive thing a phone can paint. */++ .hero-stat--clickable:hover { transform: none; box-shadow: none; }++ .navbar { backdrop-filter: none !important; -webkit-backdrop-filter: none !important; background: var(--bg-secondary) !important; }++ .hero-stat-group { content-visibility: auto; contain-intrinsic-size: 1px 120px; }++ .hero-bg-layer.active { animation: none !important; }++}++@media (prefers-reduced-motion: reduce) { .hero-chart-svg .bar { animation: none; } .hero-chart, .hero-chart-overlay { animation: none; } }@@@ -62,7 +62,7 @@</script><!-- Stylesheets -->-- <link rel="stylesheet" href="/css/style.css?v=142">++ <link rel="stylesheet" href="/css/style.css?v=143"><link rel="stylesheet" href="/css/chat-shared.css?v=1"><link rel="stylesheet" href="/css/broadcast.css?v=11"><link rel="stylesheet" href="/css/cosmetics.css?v=2">@@@ -5035,7 +5035,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=132" defer></script>++<script src="/js/app.js?v=133" 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>@@@ -1067,7 +1067,10 @@ function updateNavHeroTransparency() {const atTop = (window.scrollY || window.pageYOffset || 0) < 28;nav.classList.toggle('nav-hero-top', !!homeActive && atTop);}--window.addEventListener('scroll', updateNavHeroTransparency, { passive: true });++// One class toggle per animation frame at most — the handler fired on every scroll event,++// which on phones (with a blurred nav over an animated hero) showed up as scroll jank.++let _navScrollRaf = 0;++window.addEventListener('scroll', () => { if (_navScrollRaf) return; _navScrollRaf = requestAnimationFrame(() => { _navScrollRaf = 0; updateNavHeroTransparency(); }); }, { passive: true });window.addEventListener('resize', updateNavHeroTransparency, { passive: true });/* ── Nav Dropdown Helpers ──────────────────────────────────────── */@@@ -1229,8 +1232,8 @@ function renderHeroStats(stats) {const now = [{ cls: stats.liveNow > 0 ? 'hero-stat--live' : '', icon: stats.liveNow > 0 ? 'fa-circle' : 'fa-circle-dot', num: stats.liveNow, label: 'Live', title: stats.liveNow > 0 ? 'Streams live right now' : 'Nobody is live right now — check Recently Online below' },{ cls: stats.viewersNow > 0 ? 'hero-stat--live' : '', icon: 'fa-eye', num: stats.viewersNow, label: 'Watching', title: 'Viewers watching right now' },-- { icon: 'fa-fire', num: stats.weeklyActive, label: 'Active', title: 'Active this week — chatters incl. anons & relays' },-- { icon: 'fa-user-plus', num: stats.weeklyVisitors, label: 'New Visitors', title: 'New unique visitors this week' },++ { icon: 'fa-fire', num: stats.weeklyActive, label: 'Active · 7d', title: 'People who chatted in the last 7 days', desc: 'Distinct chatters in the last 7 days — signed-in users, anonymous chatters and relayed (Twitch/Kick/YouTube) chatters, each counted once.', metric: 'active' },++ { icon: 'fa-user-plus', num: stats.weeklyVisitors, label: 'Visitors · 7d', title: 'First-time visitors in the last 7 days', desc: 'Browsers seen on the site for the first time in the last 7 days (a privacy-safe fingerprint, no account needed). A proxy for new people showing up, not just chatting.', metric: 'visitors' },];// 24h viewer sparkline (5-minute samples) — trends read better than a snapshot.const trend = Array.isArray(stats.viewerTrend) ? stats.viewerTrend : [];@@@ -1250,24 +1253,24 @@ function renderHeroStats(stats) {// ── Community ────────────────────────────────────────────────groups.push({kicker: 'Community', icon: 'fa-people-group', 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 },-- { icon: 'fa-couch', num: stats.hoursWatched, label: 'Hrs Watched', title: 'Hours the community has spent watching streams', unit: 'h' },++ { icon: 'fa-satellite-dish', num: stats.streamers, label: 'Streamers', metric: 'streamers', title: 'People who have gone live' },++ { icon: 'fa-users', num: stats.users, label: 'Users', metric: 'users', title: 'Registered users', recent: R.users },++ { icon: 'fa-user-secret', num: stats.anons, label: 'Anons', metric: 'anons', title: 'Anonymous chatters ever seen', recent: R.anons },++ { icon: 'fa-heart', num: stats.follows, label: 'Follows', metric: 'follows', title: 'Channel follows', recent: R.follows },++ { icon: 'fa-comments', num: stats.chatMessages, label: 'Messages', metric: 'messages', title: 'Chat messages sent', recent: R.messages },++ { icon: 'fa-couch', num: stats.hoursWatched, label: 'Hrs Watched', metric: 'hoursWatched', title: 'Hours the community has spent watching streams', unit: 'h' },],});// ── Economy ──────────────────────────────────────────────────groups.push({kicker: 'Economy', icon: 'fa-coins', rows: [-- { icon: 'fa-hand-holding-dollar', num: stats.vibesTipped, label: 'Vibes Tipped', title: 'Vibes donated between people (100 Vibes = $1)', recent: R.vibes },-- { icon: 'fa-hand-holding-heart', num: stats.supporters, label: 'Supporters', title: 'People who have tipped Vibes to a streamer' },-- { icon: 'fa-cart-shopping', num: stats.vibesBought, label: 'Vibes Bought', title: 'Vibes purchased with real money (PowerChat, card, PayPal, crypto)', recent: R.vibesBought },-- { icon: 'fa-star', num: stats.activeSubs, label: 'Subs', title: 'Active channel subscriptions', recent: R.subs },-- { icon: 'fa-coins', num: stats.pointsEarned, label: 'Points Earned', title: 'Channel points earned by viewers (watching, chatting, following)', recent: R.points },-- { icon: 'fa-gift', num: stats.pointsSpent, label: 'Points Spent', title: `Channel points spent on rewards · ${_fmtCount(stats.redemptions || 0)} rewards redeemed`, recent: R.pointsSpent, sub: stats.redemptions ? `${_fmtCount(stats.redemptions)} rewards` : '' },++ { icon: 'fa-hand-holding-dollar', num: stats.vibesTipped, label: 'Vibes Tipped', metric: 'vibes', title: 'Vibes donated between people (100 Vibes = $1)', recent: R.vibes },++ { icon: 'fa-hand-holding-heart', num: stats.supporters, label: 'Supporters', metric: 'supporters', title: 'People who have tipped Vibes to a streamer' },++ { icon: 'fa-cart-shopping', num: stats.vibesBought, label: 'Vibes Bought', metric: 'vibesBought', title: 'Vibes purchased with real money (PowerChat, card, PayPal, crypto)', recent: R.vibesBought },++ { icon: 'fa-star', num: stats.activeSubs, label: 'Subs', metric: 'subs', title: 'Active channel subscriptions', recent: R.subs },++ { icon: 'fa-coins', num: stats.pointsEarned, label: 'Points Earned', metric: 'points', title: 'Channel points earned by viewers (watching, chatting, following)', recent: R.points },++ { icon: 'fa-gift', num: stats.pointsSpent, label: 'Points Spent', metric: 'pointsSpent', title: `Channel points spent on rewards · ${_fmtCount(stats.redemptions || 0)} rewards redeemed`, recent: R.pointsSpent, sub: stats.redemptions ? `${_fmtCount(stats.redemptions)} rewards` : '' },{ icon: 'fa-bullseye', num: stats.goalsActive, label: 'Goals', title: `Donation goals running now · ${stats.goalsReached || 0} reached so far`, sub: stats.goalsReached ? `${_fmtCount(stats.goalsReached)} reached` : '' },],});@@@ -1275,25 +1278,30 @@ function renderHeroStats(stats) {// ── Archive ──────────────────────────────────────────────────groups.push({kicker: 'Archive', icon: 'fa-box-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 },-- { 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-tower-broadcast', num: stats.liveSessions, label: 'Sessions', metric: 'sessions', title: 'Total stream sessions', recent: R.sessions },++ { icon: 'fa-film', num: stats.vods, label: 'VODs', metric: 'vods', title: 'Recorded videos', recent: R.vods },++ { icon: 'fa-scissors', num: stats.clips, label: 'Clips', metric: 'clips', title: 'Clips created', recent: R.clips },++ { icon: 'fa-clock', num: stats.streamHours, label: 'Hours', metric: 'hours', title: 'Hours of video archived', recent: R.hours, unit: 'h' },++ { icon: 'fa-brain', num: stats.aiMemories, label: 'AI Moments', metric: 'aiMoments', title: 'Moments the AI remembers across every stream', recent: R.aiMoments },++ { icon: 'fa-face-grin-squint', num: stats.emotes, label: 'Emotes', metric: '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 && rec.w > 0) ? `+${_fmtCount(rec.w)}${u} wk` : '';++ // Rolling-window deltas: the small sub-line is "+N in 7d" (was the cryptic "+N wk"); the++ // custom tooltip (data-tip, see _heroTooltip) spells out 24h / 7d / 30d and what the++ // number means. Every chip with a series behind it is clickable → over-time chart.++ const recSub = (rec, u = '') => (rec && rec.w > 0) ? `+${_fmtCount(rec.w)}${u} in 7d` : '';const chip = (r) => {if (r.html) return r.html; // pre-rendered chips (sparkline)-- 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>`;++ const tip = {++ label: r.label, title: r.title || '', desc: r.desc || '',++ recent: r.recent ? { d: r.recent.d, w: r.recent.w, m: r.recent.m, unit: r.unit || '' } : null,++ metric: r.metric || null,++ };++ const clickable = !!r.metric;++ return `<div class="hero-stat ${r.cls || ''} ${clickable ? 'hero-stat--clickable' : ''}" data-tip="${esc(JSON.stringify(tip))}" ${clickable ? `data-metric="${r.metric}" role="button" tabindex="0" aria-label="${esc(r.label)} — show over time"` : ''}><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}${clickable ? ' <i class="fa-solid fa-chart-line hero-stat-chart-hint"></i>' : ''}</span>${sub ? `<span class="hero-stat-sub">${sub}</span>` : ''}</div></div>`;};wrap.innerHTML = groups.filter(g => g.rows.length).map(g => `<div class="hero-stat-group">@@@ -1301,6 +1309,101 @@ function renderHeroStats(stats) {<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));++ _heroBindInteractions(wrap);++}++++// ── Hero stat tooltips + click-through charts ───────────────────++let _heroTipEl = null;++function _heroTipShow(chip) {++ let tip; try { tip = JSON.parse(chip.dataset.tip || 'null'); } catch { tip = null; }++ if (!tip) return;++ if (!_heroTipEl) { _heroTipEl = document.createElement('div'); _heroTipEl.className = 'hero-tip'; document.body.appendChild(_heroTipEl); }++ const u = tip.recent?.unit || '';++ _heroTipEl.innerHTML = `<div class="hero-tip-title">${esc(tip.label)}</div>++ <div class="hero-tip-desc">${esc(tip.desc || tip.title)}</div>++ ${tip.recent ? `<div class="hero-tip-recent">++ <div><b>+${_fmtCount(tip.recent.d)}${u}</b><span>24 h</span></div>++ <div><b>+${_fmtCount(tip.recent.w)}${u}</b><span>7 days</span></div>++ <div><b>+${_fmtCount(tip.recent.m)}${u}</b><span>30 days</span></div>++ </div>` : ''}++ ${tip.metric ? '<div class="hero-tip-cta"><i class="fa-solid fa-chart-line"></i> Tap for the last 30 / 90 days</div>' : ''}`;++ const r = chip.getBoundingClientRect();++ _heroTipEl.style.left = '0px'; _heroTipEl.style.top = '0px';++ _heroTipEl.classList.add('is-visible');++ const w = _heroTipEl.offsetWidth, h = _heroTipEl.offsetHeight;++ let left = Math.min(Math.max(8, r.left + r.width / 2 - w / 2), window.innerWidth - w - 8);++ let top = r.top - h - 10;++ if (top < 8) top = r.bottom + 10;++ _heroTipEl.style.left = `${left}px`; _heroTipEl.style.top = `${top}px`;++}++function _heroTipHide() { if (_heroTipEl) _heroTipEl.classList.remove('is-visible'); }++function _heroBindInteractions(wrap) {++ const fine = window.matchMedia && window.matchMedia('(hover: hover)').matches;++ wrap.querySelectorAll('.hero-stat[data-tip]').forEach(chip => {++ if (fine) {++ chip.addEventListener('mouseenter', () => _heroTipShow(chip));++ chip.addEventListener('mouseleave', _heroTipHide);++ }++ chip.addEventListener('focus', () => _heroTipShow(chip));++ chip.addEventListener('blur', _heroTipHide);++ if (chip.dataset.metric) {++ const open = () => { _heroTipHide(); _heroStatModal(chip); };++ chip.addEventListener('click', open);++ chip.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(); } });++ }++ });++ window.addEventListener('scroll', _heroTipHide, { passive: true });++}++const _HERO_METRIC_ICON = { users: 'fa-users', anons: 'fa-user-secret', visitors: 'fa-user-plus', active: 'fa-fire', follows: 'fa-heart', messages: 'fa-comments', sessions: 'fa-tower-broadcast', streamers: 'fa-satellite-dish', vods: 'fa-film', clips: 'fa-scissors', hours: 'fa-clock', hoursWatched: 'fa-couch', aiMoments: 'fa-brain', vibes: 'fa-hand-holding-dollar', supporters: 'fa-hand-holding-heart', vibesBought: 'fa-cart-shopping', subs: 'fa-star', points: 'fa-coins', pointsSpent: 'fa-gift', redemptions: 'fa-gift', emotes: 'fa-face-grin-squint' };++async function _heroStatModal(chip) {++ let tip; try { tip = JSON.parse(chip.dataset.tip || '{}'); } catch { tip = {}; }++ const metric = chip.dataset.metric;++ document.querySelector('.hero-chart-overlay')?.remove();++ const overlay = document.createElement('div');++ overlay.className = 'hero-chart-overlay';++ overlay.innerHTML = `<div class="hero-chart" role="dialog" aria-modal="true" aria-label="${esc(tip.label || metric)} over time">++ <div class="hero-chart-head"><h3><i class="fa-solid ${_HERO_METRIC_ICON[metric] || 'fa-chart-line'}"></i> ${esc(tip.label || metric)}</h3><button class="hero-chart-close" aria-label="Close">×</button></div>++ <p class="hero-chart-desc">${esc(tip.desc || tip.title || '')}</p>++ <div class="hero-chart-ranges"><button data-days="7">7 days</button><button data-days="30" class="active">30 days</button><button data-days="90">90 days</button></div>++ <div class="hero-chart-body"><div class="hero-chart-loading"><i class="fa-solid fa-circle-notch fa-spin"></i></div></div>++ <div class="hero-chart-foot"></div>++ </div>`;++ document.body.appendChild(overlay);++ const close = () => { overlay.remove(); document.removeEventListener('keydown', onKey); };++ const onKey = (e) => { if (e.key === 'Escape') close(); };++ document.addEventListener('keydown', onKey);++ overlay.addEventListener('click', (e) => { if (e.target === overlay || e.target.closest('.hero-chart-close')) close(); });++ const unit = tip.recent?.unit || '';++ const load = async (days) => {++ const body = overlay.querySelector('.hero-chart-body'), foot = overlay.querySelector('.hero-chart-foot');++ body.innerHTML = '<div class="hero-chart-loading"><i class="fa-solid fa-circle-notch fa-spin"></i></div>';++ try {++ const d = await api(`/home/stats/series/${encodeURIComponent(metric)}?days=${days}`);++ body.innerHTML = _heroChartSvg(d.points, unit);++ const best = d.points.reduce((a, p) => (p.value > a.value ? p : a), { value: -1 });++ foot.innerHTML = `<span><b>${_fmtCount(d.total)}${unit}</b> in the last ${d.days} days</span><span>avg <b>${_fmtCount(d.total / d.days)}${unit}</b> / day</span>${best.value > 0 ? `<span>best day <b>${_fmtCount(best.value)}${unit}</b> · ${esc(_heroDay(best.day))}</span>` : ''}`;++ } catch { body.innerHTML = '<div class="hero-chart-loading">No data for this metric yet.</div>'; foot.innerHTML = ''; }++ };++ overlay.querySelectorAll('.hero-chart-ranges button').forEach(b => b.addEventListener('click', () => {++ overlay.querySelectorAll('.hero-chart-ranges button').forEach(x => x.classList.toggle('active', x === b));++ load(Number(b.dataset.days));++ }));++ load(30);++}++function _heroDay(iso) { try { return new Date(iso + 'T00:00:00Z').toLocaleDateString(undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' }); } catch { return iso; } }++function _heroChartSvg(points, unit = '') {++ const W = 720, H = 220, padL = 40, padR = 10, padT = 12, padB = 26;++ const n = points.length;++ const max = Math.max(1, ...points.map(p => p.value));++ const x = (i) => padL + (i + 0.5) * ((W - padL - padR) / n);++ const y = (v) => padT + (H - padT - padB) * (1 - v / max);++ const bw = Math.max(2, ((W - padL - padR) / n) * (n > 40 ? 0.8 : 0.62));++ const bars = points.map((p, i) => `<rect class="bar" x="${(x(i) - bw / 2).toFixed(1)}" y="${y(p.value).toFixed(1)}" width="${bw.toFixed(1)}" height="${(H - padB - y(p.value)).toFixed(1)}" rx="2" style="animation-delay:${(i * (0.35 / n)).toFixed(3)}s"><title>${esc(_heroDay(p.day))}: ${_fmtCount(p.value)}${unit}</title></rect>`).join('');++ const line = points.map((p, i) => `${x(i).toFixed(1)},${y(p.value).toFixed(1)}`).join(' ');++ const grid = [0, 0.5, 1].map(f => { const yy = y(max * f); return `<line class="grid" x1="${padL}" x2="${W - padR}" y1="${yy.toFixed(1)}" y2="${yy.toFixed(1)}"></line><text x="${padL - 6}" y="${(yy + 3).toFixed(1)}" text-anchor="end">${_fmtCount(max * f)}</text>`; }).join('');++ const labelEvery = n > 40 ? Math.ceil(n / 8) : n > 10 ? Math.ceil(n / 6) : 1;++ const labels = points.map((p, i) => (i % labelEvery === 0 || i === n - 1) ? `<text x="${x(i).toFixed(1)}" y="${H - 8}" text-anchor="middle">${esc(_heroDay(p.day))}</text>` : '').join('');++ return `<svg class="hero-chart-svg" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" aria-hidden="true">${grid}${bars}${n > 1 ? `<polyline class="line" points="${line}"></polyline>` : ''}${labels}</svg>`;}// ── Hero floating thumbnail collage ─────────────────────────────@@@ -4551,8 +4654,12 @@ function _aiTimelineMomentHTML(mom, vodId) {try { tags = typeof mom.tags === 'string' ? JSON.parse(mom.tags) : (mom.tags || []); } catch { tags = []; }const tagHTML = (Array.isArray(tags) ? tags : []).slice(0, 4).map(t => `<span class="ai-tl-tag">${esc(String(t))}</span>`).join('');-- const thumb = mom.thumbnail_url-- ? `<img class="ai-tl-moment-thumb" src="${esc(mom.thumbnail_url)}" alt="" loading="lazy" onerror="this.remove()">`++ // Older moments point at the rotating live thumbnail, which is gone (the server answers++ // those with a placeholder pixel, so onerror never fires and a white box appears).++ // Newer moments carry their own persisted frame under /data/ai-moments/.++ const thumbUrl = (mom.thumbnail_url && !/\/api\/thumbnails\/stream-/.test(mom.thumbnail_url)) ? mom.thumbnail_url : '';++ const thumb = thumbUrl++ ? `<img class="ai-tl-moment-thumb" src="${esc(thumbUrl)}" alt="" loading="lazy" onerror="this.remove()">`: '';const jump = vodId? `<a class="ai-tl-stamp" href="/vod/${vodId}?t=${off}" onclick="return handleLinkClick(event, '/vod/${vodId}?t=${off}')" title="Watch this moment"><i class="fa-solid fa-play"></i> ${stamp}</a>`@@@ -4623,6 +4730,32 @@ function _aiTimelineSessionHTML(s) {<div class="ai-tl-transcript" hidden></div></div>`;}++// Map the audio model's many labels onto a few readable families with an icon. Returns++// null for labels that are speech-like or pure noise — those are not "sounds" worth a chip.++const _AI_SOUND_FAMILIES = [++ { key: 'rain', icon: 'fa-cloud-rain', label: 'Rain', re: /rain|drizzle|water|drip|splash|stream|river/i },++ { key: 'thunder', icon: 'fa-bolt', label: 'Thunder', re: /thunder/i },++ { key: 'wind', icon: 'fa-wind', label: 'Wind', re: /wind|breeze|rustl/i },++ { key: 'music', icon: 'fa-music', label: 'Music', re: /music|song|guitar|piano|drum|synth|beat|melody|singing|choir|hip hop|rock|jazz|techno|electronic/i },++ { key: 'laugh', icon: 'fa-face-laugh', label: 'Laughter', re: /laugh|giggle|chuckle|snicker/i },++ { key: 'explosion', icon: 'fa-burst', label: 'Explosion', re: /explos|gunshot|gunfire|blast|boom|artillery|fireworks/i },++ { key: 'vehicle', icon: 'fa-car', label: 'Vehicle', re: /vehicle|car\b|engine|motor|truck|bus|traffic|boat|train|aircraft|helicopter|siren/i },++ { key: 'animal', icon: 'fa-paw', label: 'Animal', re: /dog|cat|bird|animal|bark|meow|chirp|insect|cricket|goose|duck|cow|horse/i },++ { key: 'keys', icon: 'fa-keyboard', label: 'Clicks & keys', re: /typing|keyboard|click|mouse|keys|jangl|tick|tap/i },++ { key: 'alarm', icon: 'fa-bell', label: 'Alarm / ding', re: /alarm|beep|ding|bell|ring|notification|chime/i },++ { key: 'crowd', icon: 'fa-people-group', label: 'Crowd', re: /crowd|applause|cheer|chatter|hubbub/i },++ { key: 'kitchen', icon: 'fa-utensils', label: 'Kitchen', re: /siz{1,2}l|fry|boil|cutlery|dish|kitchen|microwave|blender|chop/i },++ { key: 'tools', icon: 'fa-screwdriver-wrench', label: 'Tools', re: /drill|hammer|saw|tool|grind|sand|screw|crackl|rattle|clank|clink|chink|metal/i },++ { key: 'breath', icon: 'fa-lungs', label: 'Breath / sigh', re: /sigh|breath|gasp|yawn|cough|sneeze|snif/i },++ { key: 'door', icon: 'fa-door-open', label: 'Doors & steps', re: /door|footstep|walk|knock|creak/i },++ { key: 'game', icon: 'fa-gamepad', label: 'Game audio', re: /video game|game|sound effect|whoosh|swoosh|zap|arcade/i },++];++function _aiSoundFamily(label) {++ const l = String(label || '').trim();++ if (!l || /^(speech|conversation|narration|monologue|male speech|female speech|child speech|silence|noise|white noise|static|hum|inside|outside|room)/i.test(l)) return null;++ for (const f of _AI_SOUND_FAMILIES) if (f.re.test(l)) return { key: f.key, icon: f.icon, label: f.label };++ return { key: 'other', icon: 'fa-volume-high', label: l.length > 28 ? l.slice(0, 26) + '…' : l };++}function _fmtCount(n) { n = Number(n) || 0; return n >= 1000 ? (n / 1000).toFixed(n >= 10000 ? 0 : 1).replace(/\.0$/, '') + 'k' : String(n); }// Expand/collapse a session's moments, building the moment DOM only on first expand.@@@ -4655,26 +4788,48 @@ async function _aiTlToggleTranscript(btn) {const data = await api(`/chat-ai/transcript/${session.dataset.sid}`);const segs = data.segments || [];const vod = data.vodId;-- // Interleave spoken lines with detected non-speech sounds so the timeline reads-- // as everything that happened audibly, in order — not just the words.++ // Speech and detected sounds are different things: speech reads as lines, sounds++ // as a quiet strip of chips between them (grouped, deduplicated, family-merged —++ // "Rain / Raindrop / Rain on surface / Water" is one "Rain" chip, not four rows).const rows = [-- ...segs.map(sg => ({ t: sg.start || 0, kind: 'speech', text: sg.text || '' })),-- ...(data.events || []).map(e => ({-- t: e.start_sec || 0, kind: 'sound',-- text: e.label + (e.confidence != null ? ` (${Number(e.confidence).toFixed(2)})` : ''),-- })),++ ...segs.map(sg => ({ t: sg.start || 0, kind: 'speech', text: String(sg.text || '').replace(/^\s*(?:>>|--?|•)\s*/, '').trim() })).filter(r => r.text),++ ...(data.events || []).map(e => ({ t: e.start_sec || 0, kind: 'sound', label: String(e.label || ''), conf: Number(e.confidence) || 0 })),].sort((a, b) => a.t - b.t);if (!rows.length) { box.innerHTML = '<p class="muted" style="padding:6px">No transcript available.</p>'; return; }-- const cov = data.coverageSec-- ? `<div class="muted" style="padding:4px 6px;font-size:.8rem">${_aiTimeFmt(data.coverageSec)} of speech transcribed</div>` : '';-- box.innerHTML = cov + rows.map(r => {-- const stamp = _aiTimeFmt(r.t);-- const jump = vod ? `<a class="ai-tl-ts" href="/vod/${vod}?t=${Math.floor(r.t)}" onclick="return handleLinkClick(event, '/vod/${vod}?t=${Math.floor(r.t)}')">${stamp}</a>` : `<span class="ai-tl-ts">${stamp}</span>`;-- const body = r.kind === 'sound'-- ? `<span class="ai-tl-tr-text muted"><i class="fa-solid fa-volume-high"></i> ${esc(r.text)}</span>`-- : `<span class="ai-tl-tr-text">${esc(r.text)}</span>`;-- return `<div class="ai-tl-tr-line">${jump}${body}</div>`;++ const blocks = [];++ for (const r of rows) {++ if (r.kind === 'speech') { blocks.push(r); continue; }++ const fam = _aiSoundFamily(r.label);++ if (!fam) continue; // speech-like / noise labels are not "sounds"++ if (r.conf && r.conf < 0.3) continue; // too unsure to show++ const last = blocks[blocks.length - 1];++ if (last && last.kind === 'sounds') {++ const hit = last.items.find(i => i.key === fam.key);++ if (hit) { hit.n++; hit.conf = Math.max(hit.conf, r.conf); } else last.items.push({ ...fam, n: 1, conf: r.conf, t: r.t });++ last.tEnd = r.t;++ } else blocks.push({ kind: 'sounds', t: r.t, tEnd: r.t, items: [{ ...fam, n: 1, conf: r.conf, t: r.t }] });++ }++ const nSpeech = blocks.filter(b => b.kind === 'speech').length, nSound = blocks.filter(b => b.kind === 'sounds').length;++ const cov = data.coverageSec ? `${_aiTimeFmt(data.coverageSec)} of speech transcribed` : '';++ const jumpFor = (t, cls = 'ai-tl-ts') => vod ? `<a class="${cls}" href="/vod/${vod}?t=${Math.floor(t)}" onclick="return handleLinkClick(event, '/vod/${vod}?t=${Math.floor(t)}')" title="Watch from here">${_aiTimeFmt(t)}</a>` : `<span class="${cls}">${_aiTimeFmt(t)}</span>`;++ const toolbar = `<div class="ai-tl-tr-bar">++ <div class="ai-tl-tr-filter" role="tablist">++ <button type="button" class="active" data-f="all">All</button>++ <button type="button" data-f="speech"><i class="fa-solid fa-comment"></i> Speech <span>${nSpeech}</span></button>++ <button type="button" data-f="sounds"><i class="fa-solid fa-wave-square"></i> Sounds <span>${nSound}</span></button>++ </div>++ <span class="ai-tl-tr-hint">${cov ? cov + ' · ' : ''}sounds are detected by the audio model — hover a chip for its confidence</span>++ </div>`;++ box.innerHTML = toolbar + blocks.map(b => {++ if (b.kind === 'speech') return `<div class="ai-tl-tr-line ai-tl-tr-speech">${jumpFor(b.t)}<span class="ai-tl-tr-text">${esc(b.text)}</span></div>`;++ const span = b.tEnd > b.t + 2 ? `${_aiTimeFmt(b.t)}–${_aiTimeFmt(b.tEnd)}` : _aiTimeFmt(b.t);++ const chips = b.items.sort((x, y) => y.n - x.n).map(i => `<span class="ai-tl-sound ai-tl-sound--${i.key}" title="${esc(i.label)} · confidence ${(i.conf * 100).toFixed(0)}%"><i class="fa-solid ${i.icon}"></i>${esc(i.label)}${i.n > 1 ? `<b>×${i.n}</b>` : ''}</span>`).join('');++ return `<div class="ai-tl-tr-line ai-tl-tr-sounds">${jumpFor(b.t, 'ai-tl-ts ai-tl-ts--sound')}<div class="ai-tl-sound-strip" title="${esc(span)}">${chips}</div></div>`;}).join('');++ box.querySelectorAll('.ai-tl-tr-filter button').forEach(btn => btn.addEventListener('click', () => {++ box.querySelectorAll('.ai-tl-tr-filter button').forEach(b => b.classList.toggle('active', b === btn));++ box.dataset.filter = btn.dataset.f;++ }));} catch { box.innerHTML = '<p class="muted" style="padding:6px">Couldn\'t load transcript.</p>'; box.dataset.built = ''; }}}@@@ -37,6 +37,36 @@ function captureMemoryNow(stream, { allowFfmpeg = true, reason = 'manual' } = {}return p;}++// ── Moment frames ────────────────────────────────────────────++// The live thumbnail a moment used to point at rotates every few seconds and is deleted++// within the hour, so every moment in the timeline ended up showing the placeholder++// pixel (a white box). Keep a small JPEG of the analysed frame per moment instead,++// under data/ai-moments/<streamId>/<offset>.jpg (served at /data/ai-moments/…, ~15 KB).++const MOMENTS_DIR = require('path').resolve(process.env.AI_MOMENTS_PATH || './data/ai-moments');++async function _persistMomentFrame(image, streamId, offset) {++ if (!image) return null;++ try {++ const fs = require('fs'), path = require('path');++ const sharp = require('sharp');++ const dir = path.join(MOMENTS_DIR, String(streamId));++ fs.mkdirSync(dir, { recursive: true });++ const file = `${Math.max(0, Math.round(offset))}.jpg`;++ const input = Buffer.isBuffer(image) ? image : fs.readFileSync(image);++ await sharp(input).resize({ width: 320, withoutEnlargement: true }).jpeg({ quality: 72 }).toFile(path.join(dir, file));++ return `/data/ai-moments/${streamId}/${file}`;++ } catch (e) {++ console.warn('[AI-See] moment frame not saved:', e.message);++ return null;++ }++}++// A live thumbnail URL (/api/thumbnails/stream-…) is never worth storing — it will be gone++// by the time anyone looks; Media-hosted VOD thumbnails are stable and fine.++function _stableThumb(url) {++ const u = String(url || '');++ if (!u || /\/api\/thumbnails\/stream-/.test(u)) return null;++ return u;++}++async function _analyzeOne(stream, { allowFfmpeg = true, reason = 'periodic' } = {}) {let image = null;if (allowFfmpeg) { try { if (vision && vision.captureFrame) image = await vision.captureFrame(stream); } catch { /* */ } }@@@ -109,7 +139,7 @@ async function _analyzeOne(stream, { allowFfmpeg = true, reason = 'periodic' } =try {db.addStreamMemory({stream_id: stream.id, user_id: stream.user_id, offset_seconds: offset,-- description: memDesc, tags: r.tags, thumbnail_url: stream.thumbnail_url || null,++ description: memDesc, tags: r.tags, thumbnail_url: await _persistMomentFrame(image, stream.id, offset) || _stableThumb(stream.thumbnail_url),transcript_json: heardSegments,});// Roll this stream's memories into an overview — but only RE-summarize when@@@ -3867,6 +3867,65 @@ function getActiveGoalsForUsers(userIds) {return out;}++/** ISO cutoff for Vibes stats (setting `stats_vibes_reset_at`), or the epoch when unset. */++function vibesStatsSince() {++ try {++ const v = String(getSetting('stats_vibes_reset_at') || '').trim();++ if (v && !isNaN(Date.parse(v))) return new Date(v).toISOString().replace('T', ' ').slice(0, 19);++ } catch { /* */ }++ return '1970-01-01 00:00:00';++}++++// ── Home stat series (click a hero stat → "over time" chart) ────────────────++// One registry entry per metric: the table, its timestamp column, what to aggregate and++// an optional WHERE. `days` buckets are computed in SQL by date(); missing days are++// filled with 0 so charts never skip a day.++const HOME_SERIES = {++ users: { table: 'users', ts: 'created_at', agg: 'COUNT(*)', where: 'COALESCE(is_banned, 0) = 0' },++ anons: { table: 'anon_ip_mappings', ts: 'created_at', agg: 'COUNT(*)' },++ visitors: { table: 'anon_ip_mappings', ts: 'created_at', agg: 'COUNT(*)' },++ follows: { table: 'follows', ts: 'created_at', agg: 'COUNT(*)' },++ messages: { table: 'chat_messages', ts: 'timestamp', agg: 'COUNT(*)', where: 'COALESCE(is_deleted, 0) = 0' },++ active: { table: 'chat_messages', ts: 'timestamp', agg: "COUNT(DISTINCT COALESCE('u' || user_id, 'a' || anon_id, 'r' || source_platform || '|' || username))", where: 'COALESCE(is_deleted, 0) = 0' },++ sessions: { table: 'streams', ts: 'created_at', agg: 'COUNT(*)' },++ streamers: { table: 'streams', ts: 'created_at', agg: 'COUNT(DISTINCT user_id)' },++ vods: { table: 'vods', ts: 'created_at', agg: 'COUNT(*)', where: 'is_public = 1 AND COALESCE(is_recording, 0) = 0' },++ clips: { table: 'clips', ts: 'created_at', agg: 'COUNT(*)', where: 'COALESCE(is_public, 1) = 1' },++ hours: { table: 'vods', ts: 'created_at', agg: 'COALESCE(SUM(duration_seconds), 0) / 3600.0', where: 'COALESCE(is_recording, 0) = 0' },++ hoursWatched:{ table: 'watch_time', ts: 'created_at', agg: 'COALESCE(SUM(minutes_watched), 0) / 60.0' },++ aiMoments: { table: 'stream_memories', ts: 'created_at', agg: 'COUNT(*)' },++ vibes: { table: 'transactions', ts: 'created_at', agg: 'COALESCE(SUM(amount), 0)', where: "type = 'donation'", vibesReset: true },++ supporters: { table: 'transactions', ts: 'created_at', agg: 'COUNT(DISTINCT from_user_id)', where: "type = 'donation' AND from_user_id IS NOT NULL", vibesReset: true },++ vibesBought: { table: 'payment_orders', ts: 'updated_at', agg: 'COALESCE(SUM(bucks), 0)', where: "kind = 'bucks' AND status = 'credited'" },++ subs: { table: 'subscriptions', ts: 'created_at', agg: 'COUNT(*)' },++ points: { table: 'coin_transactions', ts: 'created_at', agg: 'COALESCE(SUM(amount), 0)', where: 'amount > 0' },++ pointsSpent: { table: 'coin_transactions', ts: 'created_at', agg: 'COALESCE(-SUM(amount), 0)', where: 'amount < 0' },++ redemptions: { table: 'coin_redemptions', ts: 'created_at', agg: 'COUNT(*)', where: "status NOT IN ('rejected', 'refunded')" },++ emotes: { table: 'emotes', ts: 'created_at', agg: 'COUNT(*)' },++};++const HOME_SERIES_KEYS = Object.keys(HOME_SERIES);++++function getHomeStatSeries(metric, days = 30) {++ const def = HOME_SERIES[metric];++ if (!def) return null;++ days = Math.max(1, Math.min(365, parseInt(days, 10) || 30));++ const where = [`${def.ts} >= datetime('now', ?)`];++ if (def.where) where.push(def.where);++ if (def.vibesReset) where.push(`${def.ts} >= '${vibesStatsSince()}'`);++ let rows = [];++ try {++ rows = all(`SELECT date(${def.ts}) AS day, ${def.agg} AS value FROM ${def.table} WHERE ${where.join(' AND ')} GROUP BY day ORDER BY day ASC`, [`-${days - 1} days`]);++ } catch { rows = []; }++ const byDay = new Map(rows.map(r => [r.day, Number(r.value) || 0]));++ const points = [];++ for (let i = days - 1; i >= 0; i--) {++ const day = new Date(Date.now() - i * 86400000).toISOString().slice(0, 10);++ points.push({ day, value: Number((byDay.get(day) || 0).toFixed(2)) });++ }++ const total = Number(points.reduce((a, p) => a + p.value, 0).toFixed(2));++ return { metric, days, points, total, peak: Math.max(0, ...points.map(p => p.value)) };++}++function getHomeStats() {const now = Date.now();if (_homeStatsCache && (now - _homeStatsCacheAt) < _HOME_STATS_TTL) return _homeStatsCache;@@@ -3878,6 +3937,9 @@ function getHomeStats() {function _computeHomeStats() {// Each stat is isolated so a missing table / column can never blank the whole hero.const c = (sql, p = []) => { try { return get(sql, p)?.count || 0; } catch { return 0; } };++ // Vibes tipped before this instant were test money (site setting `stats_vibes_reset_at`,++ // ISO timestamp). Everything Vibes-related on the hero starts counting from it.++ const vibesSince = vibesStatsSince();// Rolling day/week/month counts for a table by its timestamp column.const winCount = (table, col, extra = '') => {const q = (w) => c(`SELECT COUNT(*) AS count FROM ${table} WHERE ${col} >= datetime('now', ?)${extra ? ' AND ' + extra : ''}`, [w]);@@@ -3896,7 +3958,7 @@ function _computeHomeStats() {// 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'`),++ vibesTipped: c(`SELECT COALESCE(SUM(amount), 0) AS count FROM transactions WHERE type = 'donation' AND created_at >= ?`, [vibesSince]),// 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).@@@ -3906,7 +3968,7 @@ function _computeHomeStats() {// Reward redemptions that stuck (not rejected / refunded).redemptions: c(`SELECT COUNT(*) AS count FROM coin_redemptions WHERE status NOT IN ('rejected', 'refunded')`),// Distinct people who have tipped Vibes to someone.-- supporters: c(`SELECT COUNT(DISTINCT from_user_id) AS count FROM transactions WHERE type = 'donation' AND from_user_id IS NOT NULL`),++ supporters: c(`SELECT COUNT(DISTINCT from_user_id) AS count FROM transactions WHERE type = 'donation' AND from_user_id IS NOT NULL AND created_at >= ?`, [vibesSince]),// Vibes bought with real money (credited purchase orders, any provider).vibesBought: c(`SELECT COALESCE(SUM(bucks), 0) AS count FROM payment_orders WHERE kind = 'bucks' AND status = 'credited'`),// Donation goals: currently running + ever reached.@@@ -3952,7 +4014,7 @@ 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'"),++ vibes: winSum('transactions', 'amount', 'created_at', `type = 'donation' AND created_at >= '${vibesSince}'`),points: winSum('coin_transactions', 'amount', 'created_at', 'amount > 0'),pointsSpent: winSum('coin_transactions', '-amount', 'created_at', 'amount < 0'),redemptions: winCount('coin_redemptions', 'created_at', "status NOT IN ('rejected', 'refunded')"),@@@ -7917,6 +7979,7 @@ function addToDonationGoal(id, amount) {}module.exports = {++ getHomeStatSeries, HOME_SERIES_KEYS, vibesStatsSince, _computeHomeStats,getVodAiState, getClipAiState,scheduleClipNotifyState, bumpClipNotifyNowState, markClipNotifiedState, getDueClipNotifies,getDb, initDb, run, get, all, close,@@@ -170,6 +170,14 @@ async function _mediaStatsCached() {return _mediaStats.data;}++// GET /api/home/stats/series/:metric?days=30 — daily values behind a hero stat.++router.get('/stats/series/:metric', (req, res) => {++ const series = db.getHomeStatSeries(String(req.params.metric), req.query.days);++ if (!series) return res.status(404).json({ error: 'Unknown metric', metrics: db.HOME_SERIES_KEYS });++ res.set('Cache-Control', 'public, max-age=120');++ res.json(series);++});++async function heroStats() {const stats = { ...db.getHomeStats() };const m = await _mediaStatsCached();@@@ -462,6 +462,11 @@ app.use(express.static(path.join(__dirname, '../public'), { setHeaders: (res, fi// Serve locally-cached song-request media files (media player page)app.use('/media', express.static(path.resolve('./data/media')));++// AI moment frames (one small JPEG per stream memory, see server/ai/stream-memory-job.js)++app.use('/data/ai-moments', express.static(path.resolve(process.env.AI_MOMENTS_PATH || './data/ai-moments'), {++ maxAge: '30d', immutable: true, index: false, dotfiles: 'deny',++}));++// Arena portraits (AI-generated fighting-game character art, see server/arena)app.use('/data/arena', express.static(path.resolve(process.env.ARENA_IMAGE_PATH || './data/arena'), {maxAge: '7d', immutable: true, index: false, dotfiles: 'deny',nnew file mode 100644@@@ -0,0 +1,61 @@++'use strict';++++// Home hero stats: the per-metric daily series behind the click-through charts, and the++// Vibes reset cutoff (`stats_vibes_reset_at`) that hides test money from the counters.++++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++++process.env.DB_PATH = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'ov-stats-')), 'live.db');++const db = require('../server/db/database');++db.initDb();++++const mk = (u, key) => Number(db.createUser({ username: u, email: `${u}@x`, password_hash: 'x', display_name: u, stream_key: key }).lastInsertRowid);++const a = mk('alice', 'a'.repeat(32)), b = mk('bob', 'b'.repeat(32));++db.run("UPDATE users SET created_at = datetime('now', '-3 days') WHERE id = ?", [a]);++db.run("UPDATE users SET created_at = datetime('now', '-1 days') WHERE id = ?", [b]);++++// ── series: registry, zero-filled days, totals ──++assert.ok(db.HOME_SERIES_KEYS.includes('users') && db.HOME_SERIES_KEYS.includes('vibes'));++assert.strictEqual(db.getHomeStatSeries('nope'), null, 'unknown metric → null');++const users7 = db.getHomeStatSeries('users', 7);++assert.strictEqual(users7.points.length, 7, 'one point per day, zero-filled');++assert.strictEqual(users7.total, 2);++assert.strictEqual(users7.points[6 - 3].value, 1, 'alice 3 days ago');++assert.strictEqual(users7.points[6 - 1].value, 1, 'bob yesterday');++assert.strictEqual(users7.points[6].value, 0, 'nobody today');++assert.strictEqual(db.getHomeStatSeries('users', 2).total, 1, 'window shrinks the total');++assert.strictEqual(db.getHomeStatSeries('users', 9999).days, 365, 'days are clamped');++console.log('✅ daily series are zero-filled and windowed');++++// ── vibes: test money before the reset never counts ──++db.run("INSERT INTO transactions (from_user_id, to_user_id, amount, type, status, created_at) VALUES (?, ?, 500, 'donation', 'completed', datetime('now', '-10 days'))", [a, b]);++db.run("INSERT INTO transactions (from_user_id, to_user_id, amount, type, status, created_at) VALUES (?, ?, 200, 'donation', 'completed', datetime('now', '-2 days'))", [b, a]);++db.run("INSERT INTO transactions (from_user_id, to_user_id, amount, type, status, created_at) VALUES (?, ?, 50, 'donation', 'completed', datetime('now', '-1 hours'))", [b, a]);++++let stats = db.getHomeStats();++assert.strictEqual(stats.vibesTipped, 750, 'no reset → everything counts');++assert.strictEqual(stats.supporters, 2);++assert.strictEqual(db.getHomeStatSeries('vibes', 30).total, 750);++++db.setSetting('stats_vibes_reset_at', new Date(Date.now() - 36 * 3600 * 1000).toISOString()); // 36 h ago++// getHomeStats is cached for a while — compute fresh through the internal path.++stats = db._computeHomeStats ? db._computeHomeStats() : null;++if (!stats) { // fall back: the cache TTL keeps the old value; verify via the series + cutoff helper instead++ assert.ok(db.vibesStatsSince() > '2000-01-01', 'cutoff parsed');++} else {++ assert.strictEqual(stats.vibesTipped, 50, 'only vibes after the reset count');++ assert.strictEqual(stats.supporters, 1);++ assert.strictEqual(stats.recent.vibes.d, 50);++ assert.strictEqual(stats.recent.vibes.m, 50, 'the 30-day window is also cut at the reset');++}++assert.strictEqual(db.getHomeStatSeries('vibes', 30).total, 50, 'series honours the reset');++assert.strictEqual(db.getHomeStatSeries('supporters', 30).total, 1);++db.setSetting('stats_vibes_reset_at', 'garbage');++assert.strictEqual(db.vibesStatsSince(), '1970-01-01 00:00:00', 'an unparseable reset value is ignored, not fatal');++console.log('✅ Vibes reset cutoff applies to counters, deltas and series');++++console.log('\n✅ All home-stats tests passed');++process.exit(0);
@@ -14104,3 +14104,82 @@ label.staff-inline-toggle.aiv-field { flex-direction:row; align-items:center; }
@@ -14104,3 +14104,82 @@ label.staff-inline-toggle.aiv-field { flex-direction:row; align-items:center; }
}
}
#arena-stat-detail { grid-column: 1 / -1; }
#arena-stat-detail { grid-column: 1 / -1; }
#arena-stat-detail:empty { display: none; }
#arena-stat-detail:empty { display: none; }
+
+/* ── AI timeline transcript: speech lines vs. sound strips ── */
+.ai-tl-tr-bar { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; margin: 0 0 8px; padding-bottom: 8px; border-bottom: 1px solid var(--border, rgba(255,255,255,0.07)); }
+.ai-tl-tr-filter { display: inline-flex; gap: 4px; }
+.ai-tl-tr-filter button { padding: 4px 10px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,0.1)); background: transparent; color: var(--text-secondary); font-size: 0.75rem; cursor: pointer; display: inline-flex; align-items: center; gap: 5px; }
+.ai-tl-tr-filter button span { font-size: 0.68rem; color: var(--text-muted); }
+.ai-tl-tr-filter button.active { background: color-mix(in srgb, var(--accent, #8b5cf6) 18%, transparent); border-color: var(--accent, #8b5cf6); color: var(--text-primary); }
+.ai-tl-tr-hint { font-size: 0.72rem; color: var(--text-muted); }
+.ai-tl-tr-speech .ai-tl-tr-text { color: var(--text-primary); }
+.ai-tl-tr-sounds { align-items: center; padding: 2px 0; }
+.ai-tl-ts--sound { color: var(--text-muted); font-weight: 600; }
+.ai-tl-sound-strip { display: flex; flex-wrap: wrap; gap: 4px; }
+.ai-tl-sound { display: inline-flex; align-items: center; gap: 5px; padding: 2px 8px; border-radius: 999px; font-size: 0.7rem; color: var(--text-secondary); background: var(--bg-elevated, rgba(255,255,255,0.04)); border: 1px solid var(--border, rgba(255,255,255,0.07)); }
+.ai-tl-sound i { font-size: 0.68rem; opacity: 0.8; }
+.ai-tl-sound b { font-weight: 700; color: var(--text-muted); margin-left: 1px; }
+.ai-tl-sound--rain, .ai-tl-sound--thunder, .ai-tl-sound--wind { color: #7cc4ff; }
+.ai-tl-sound--music { color: #d8a0ff; }
+.ai-tl-sound--laugh, .ai-tl-sound--crowd { color: #ffd166; }
+.ai-tl-sound--explosion, .ai-tl-sound--alarm { color: #ff8b6b; }
+.ai-tl-sound--vehicle, .ai-tl-sound--tools, .ai-tl-sound--keys, .ai-tl-sound--door { color: #b8c4d0; }
+.ai-tl-sound--animal, .ai-tl-sound--kitchen { color: #9be7a6; }
+.ai-tl-transcript[data-filter="speech"] .ai-tl-tr-sounds { display: none; }
+.ai-tl-transcript[data-filter="sounds"] .ai-tl-tr-speech { display: none; }
+
+/* ── Hero stats: tooltips, click-through charts, mobile ── */
+.hero-stat--clickable { cursor: pointer; }
+.hero-stat--clickable:hover, .hero-stat--clickable:focus-visible { transform: translateY(-1px); border-color: color-mix(in srgb, var(--accent) 55%, transparent); box-shadow: 0 6px 18px rgba(0, 0, 0, 0.25); outline: none; }
+.hero-stat--clickable:active { transform: translateY(0) scale(0.99); }
+.hero-stat-chart-hint { font-size: 0.6em; opacity: 0; margin-left: 4px; transition: opacity 0.15s; }
+.hero-stat--clickable:hover .hero-stat-chart-hint { opacity: 0.8; }
+.hero-tip { position: fixed; z-index: 4000; max-width: 300px; padding: 10px 12px; border-radius: 12px; background: var(--bg-card, #1a1a20); border: 1px solid var(--border-light, #3a3a44); box-shadow: 0 12px 34px rgba(0, 0, 0, 0.5); color: var(--text-primary); font-size: 0.8rem; line-height: 1.45; pointer-events: none; opacity: 0; transform: translateY(4px); transition: opacity 0.12s, transform 0.12s; }
+.hero-tip.is-visible { opacity: 1; transform: none; }
+.hero-tip-title { font-weight: 700; margin-bottom: 2px; }
+.hero-tip-desc { color: var(--text-secondary); }
+.hero-tip-recent { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin-top: 8px; }
+.hero-tip-recent div { background: var(--bg-tertiary); border-radius: 8px; padding: 5px 7px; text-align: center; }
+.hero-tip-recent b { display: block; font-size: 0.9rem; color: var(--accent-light, #a78bfa); }
+.hero-tip-recent span { font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); }
+.hero-tip-cta { margin-top: 8px; font-size: 0.7rem; color: var(--text-muted); }
+.hero-chart-overlay { position: fixed; inset: 0; z-index: 4100; background: rgba(0, 0, 0, 0.7); display: flex; align-items: center; justify-content: center; padding: 20px; animation: heroFadeIn 0.18s ease-out; }
+@keyframes heroFadeIn { from { opacity: 0; } to { opacity: 1; } }
+.hero-chart { width: min(760px, 100%); background: var(--bg-card); border: 1px solid var(--border); border-radius: 18px; padding: 18px 20px 16px; animation: heroPop 0.22s cubic-bezier(0.2, 0.9, 0.3, 1.2); }
+@keyframes heroPop { from { transform: scale(0.96); opacity: 0; } to { transform: none; opacity: 1; } }
+.hero-chart-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 6px; }
+.hero-chart-head h3 { margin: 0; font-size: 1.15rem; display: flex; align-items: center; gap: 8px; }
+.hero-chart-head h3 i { color: var(--accent); }
+.hero-chart-close { background: none; border: 0; color: var(--text-muted); font-size: 1.5rem; cursor: pointer; line-height: 1; }
+.hero-chart-desc { color: var(--text-secondary); font-size: 0.86rem; margin: 0 0 10px; }
+.hero-chart-ranges { display: inline-flex; gap: 4px; margin-bottom: 10px; }
+.hero-chart-ranges button { padding: 4px 10px; border-radius: 999px; border: 1px solid var(--border); background: transparent; color: var(--text-secondary); font-size: 0.75rem; cursor: pointer; }
+.hero-chart-ranges button.active { background: color-mix(in srgb, var(--accent) 18%, transparent); border-color: var(--accent); color: var(--text-primary); }
+.hero-chart-svg { width: 100%; height: 220px; display: block; }
+.hero-chart-svg .bar { fill: var(--accent); opacity: 0.85; transition: opacity 0.1s; transform-origin: bottom; animation: heroBarGrow 0.5s cubic-bezier(0.2, 0.8, 0.3, 1) both; }
+.hero-chart-svg .bar:hover { opacity: 1; }
+@keyframes heroBarGrow { from { transform: scaleY(0); } to { transform: scaleY(1); } }
+.hero-chart-svg .grid { stroke: var(--border); stroke-width: 1; }
+.hero-chart-svg text { fill: var(--text-muted); font-size: 10px; }
+.hero-chart-svg .line { fill: none; stroke: var(--accent-light); stroke-width: 2; stroke-linejoin: round; }
+.hero-chart-svg .area { fill: color-mix(in srgb, var(--accent) 22%, transparent); }
+.hero-chart-foot { display: flex; justify-content: space-between; gap: 10px; flex-wrap: wrap; font-size: 0.78rem; color: var(--text-muted); margin-top: 6px; }
+.hero-chart-foot b { color: var(--text-primary); }
+.hero-chart-loading { padding: 60px 0; text-align: center; color: var(--text-muted); }
+@media (max-width: 720px) {
+ /* Icons back on phones — smaller, and the label wraps instead of hiding them. */
+ .hero-stat > i { display: inline-block !important; font-size: 0.85rem; }
+ .hero-stat { padding: 7px 8px; gap: 6px; }
+ .hero-stat-label { white-space: normal; line-height: 1.15; }
+ .hero-stat-chart-hint { opacity: 0.6; }
+ .hero-tip { max-width: calc(100vw - 24px); }
+ .hero-chart { padding: 14px; }
+ .hero-chart-svg { height: 170px; }
+ /* Scroll performance: no hover transforms/shadows and no blur on phones — a blurred
+ fixed nav over an animated hero is the most expensive thing a phone can paint. */
+ .hero-stat--clickable:hover { transform: none; box-shadow: none; }
+ .navbar { backdrop-filter: none !important; -webkit-backdrop-filter: none !important; background: var(--bg-secondary) !important; }
+ .hero-stat-group { content-visibility: auto; contain-intrinsic-size: 1px 120px; }
+ .hero-bg-layer.active { animation: none !important; }
+}
+@media (prefers-reduced-motion: reduce) { .hero-chart-svg .bar { animation: none; } .hero-chart, .hero-chart-overlay { animation: none; } }
@@ -62,7 +62,7 @@
@@ -62,7 +62,7 @@
</script>
</script>
<!-- Stylesheets -->
<!-- Stylesheets -->
- <link rel="stylesheet" href="/css/style.css?v=142">
+ <link rel="stylesheet" href="/css/style.css?v=143">
<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/broadcast.css?v=11">
<link rel="stylesheet" href="/css/broadcast.css?v=11">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
@@ -5035,7 +5035,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -5035,7 +5035,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=132" defer></script>
+<script src="/js/app.js?v=133" 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>
@@ -1067,7 +1067,10 @@ function updateNavHeroTransparency() {
@@ -1067,7 +1067,10 @@ function updateNavHeroTransparency() {
const atTop = (window.scrollY || window.pageYOffset || 0) < 28;
const atTop = (window.scrollY || window.pageYOffset || 0) < 28;
nav.classList.toggle('nav-hero-top', !!homeActive && atTop);
nav.classList.toggle('nav-hero-top', !!homeActive && atTop);
}
}
-window.addEventListener('scroll', updateNavHeroTransparency, { passive: true });
+// One class toggle per animation frame at most — the handler fired on every scroll event,
+// which on phones (with a blurred nav over an animated hero) showed up as scroll jank.
+let _navScrollRaf = 0;
+window.addEventListener('scroll', () => { if (_navScrollRaf) return; _navScrollRaf = requestAnimationFrame(() => { _navScrollRaf = 0; updateNavHeroTransparency(); }); }, { passive: true });
window.addEventListener('resize', updateNavHeroTransparency, { passive: true });
window.addEventListener('resize', updateNavHeroTransparency, { passive: true });
/* ── Nav Dropdown Helpers ──────────────────────────────────────── */
/* ── Nav Dropdown Helpers ──────────────────────────────────────── */
@@ -1229,8 +1232,8 @@ function renderHeroStats(stats) {
@@ -1229,8 +1232,8 @@ function renderHeroStats(stats) {
const now = [
const now = [
{ cls: stats.liveNow > 0 ? 'hero-stat--live' : '', icon: stats.liveNow > 0 ? 'fa-circle' : 'fa-circle-dot', num: stats.liveNow, label: 'Live', title: stats.liveNow > 0 ? 'Streams live right now' : 'Nobody is live right now — check Recently Online below' },
{ cls: stats.liveNow > 0 ? 'hero-stat--live' : '', icon: stats.liveNow > 0 ? 'fa-circle' : 'fa-circle-dot', num: stats.liveNow, label: 'Live', title: stats.liveNow > 0 ? 'Streams live right now' : 'Nobody is live right now — check Recently Online below' },
{ cls: stats.viewersNow > 0 ? 'hero-stat--live' : '', icon: 'fa-eye', num: stats.viewersNow, label: 'Watching', title: 'Viewers watching right now' },
{ cls: stats.viewersNow > 0 ? 'hero-stat--live' : '', icon: 'fa-eye', num: stats.viewersNow, label: 'Watching', title: 'Viewers watching right now' },
- { icon: 'fa-fire', num: stats.weeklyActive, label: 'Active', title: 'Active this week — chatters incl. anons & relays' },
- { icon: 'fa-user-plus', num: stats.weeklyVisitors, label: 'New Visitors', title: 'New unique visitors this week' },
+ { icon: 'fa-fire', num: stats.weeklyActive, label: 'Active · 7d', title: 'People who chatted in the last 7 days', desc: 'Distinct chatters in the last 7 days — signed-in users, anonymous chatters and relayed (Twitch/Kick/YouTube) chatters, each counted once.', metric: 'active' },
+ { icon: 'fa-user-plus', num: stats.weeklyVisitors, label: 'Visitors · 7d', title: 'First-time visitors in the last 7 days', desc: 'Browsers seen on the site for the first time in the last 7 days (a privacy-safe fingerprint, no account needed). A proxy for new people showing up, not just chatting.', metric: 'visitors' },
];
];
// 24h viewer sparkline (5-minute samples) — trends read better than a snapshot.
// 24h viewer sparkline (5-minute samples) — trends read better than a snapshot.
const trend = Array.isArray(stats.viewerTrend) ? stats.viewerTrend : [];
const trend = Array.isArray(stats.viewerTrend) ? stats.viewerTrend : [];
@@ -1250,24 +1253,24 @@ function renderHeroStats(stats) {
@@ -1250,24 +1253,24 @@ function renderHeroStats(stats) {
// ── Community ────────────────────────────────────────────────
// ── Community ────────────────────────────────────────────────
groups.push({
groups.push({
kicker: 'Community', icon: 'fa-people-group', rows: [
kicker: 'Community', icon: 'fa-people-group', 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 },
- { icon: 'fa-couch', num: stats.hoursWatched, label: 'Hrs Watched', title: 'Hours the community has spent watching streams', unit: 'h' },
+ { icon: 'fa-satellite-dish', num: stats.streamers, label: 'Streamers', metric: 'streamers', title: 'People who have gone live' },
+ { icon: 'fa-users', num: stats.users, label: 'Users', metric: 'users', title: 'Registered users', recent: R.users },
+ { icon: 'fa-user-secret', num: stats.anons, label: 'Anons', metric: 'anons', title: 'Anonymous chatters ever seen', recent: R.anons },
+ { icon: 'fa-heart', num: stats.follows, label: 'Follows', metric: 'follows', title: 'Channel follows', recent: R.follows },
+ { icon: 'fa-comments', num: stats.chatMessages, label: 'Messages', metric: 'messages', title: 'Chat messages sent', recent: R.messages },
+ { icon: 'fa-couch', num: stats.hoursWatched, label: 'Hrs Watched', metric: 'hoursWatched', title: 'Hours the community has spent watching streams', unit: 'h' },
],
],
});
});
// ── Economy ──────────────────────────────────────────────────
// ── Economy ──────────────────────────────────────────────────
groups.push({
groups.push({
kicker: 'Economy', icon: 'fa-coins', rows: [
kicker: 'Economy', icon: 'fa-coins', rows: [
- { icon: 'fa-hand-holding-dollar', num: stats.vibesTipped, label: 'Vibes Tipped', title: 'Vibes donated between people (100 Vibes = $1)', recent: R.vibes },
- { icon: 'fa-hand-holding-heart', num: stats.supporters, label: 'Supporters', title: 'People who have tipped Vibes to a streamer' },
- { icon: 'fa-cart-shopping', num: stats.vibesBought, label: 'Vibes Bought', title: 'Vibes purchased with real money (PowerChat, card, PayPal, crypto)', recent: R.vibesBought },
- { icon: 'fa-star', num: stats.activeSubs, label: 'Subs', title: 'Active channel subscriptions', recent: R.subs },
- { icon: 'fa-coins', num: stats.pointsEarned, label: 'Points Earned', title: 'Channel points earned by viewers (watching, chatting, following)', recent: R.points },
- { icon: 'fa-gift', num: stats.pointsSpent, label: 'Points Spent', title: `Channel points spent on rewards · ${_fmtCount(stats.redemptions || 0)} rewards redeemed`, recent: R.pointsSpent, sub: stats.redemptions ? `${_fmtCount(stats.redemptions)} rewards` : '' },
+ { icon: 'fa-hand-holding-dollar', num: stats.vibesTipped, label: 'Vibes Tipped', metric: 'vibes', title: 'Vibes donated between people (100 Vibes = $1)', recent: R.vibes },
+ { icon: 'fa-hand-holding-heart', num: stats.supporters, label: 'Supporters', metric: 'supporters', title: 'People who have tipped Vibes to a streamer' },
+ { icon: 'fa-cart-shopping', num: stats.vibesBought, label: 'Vibes Bought', metric: 'vibesBought', title: 'Vibes purchased with real money (PowerChat, card, PayPal, crypto)', recent: R.vibesBought },
+ { icon: 'fa-star', num: stats.activeSubs, label: 'Subs', metric: 'subs', title: 'Active channel subscriptions', recent: R.subs },
+ { icon: 'fa-coins', num: stats.pointsEarned, label: 'Points Earned', metric: 'points', title: 'Channel points earned by viewers (watching, chatting, following)', recent: R.points },
+ { icon: 'fa-gift', num: stats.pointsSpent, label: 'Points Spent', metric: 'pointsSpent', title: `Channel points spent on rewards · ${_fmtCount(stats.redemptions || 0)} rewards redeemed`, recent: R.pointsSpent, sub: stats.redemptions ? `${_fmtCount(stats.redemptions)} rewards` : '' },
{ icon: 'fa-bullseye', num: stats.goalsActive, label: 'Goals', title: `Donation goals running now · ${stats.goalsReached || 0} reached so far`, sub: stats.goalsReached ? `${_fmtCount(stats.goalsReached)} reached` : '' },
{ icon: 'fa-bullseye', num: stats.goalsActive, label: 'Goals', title: `Donation goals running now · ${stats.goalsReached || 0} reached so far`, sub: stats.goalsReached ? `${_fmtCount(stats.goalsReached)} reached` : '' },
],
],
});
});
@@ -1275,25 +1278,30 @@ function renderHeroStats(stats) {
@@ -1275,25 +1278,30 @@ function renderHeroStats(stats) {
// ── Archive ──────────────────────────────────────────────────
// ── Archive ──────────────────────────────────────────────────
groups.push({
groups.push({
kicker: 'Archive', icon: 'fa-box-archive', rows: [
kicker: 'Archive', icon: 'fa-box-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 },
- { 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-tower-broadcast', num: stats.liveSessions, label: 'Sessions', metric: 'sessions', title: 'Total stream sessions', recent: R.sessions },
+ { icon: 'fa-film', num: stats.vods, label: 'VODs', metric: 'vods', title: 'Recorded videos', recent: R.vods },
+ { icon: 'fa-scissors', num: stats.clips, label: 'Clips', metric: 'clips', title: 'Clips created', recent: R.clips },
+ { icon: 'fa-clock', num: stats.streamHours, label: 'Hours', metric: 'hours', title: 'Hours of video archived', recent: R.hours, unit: 'h' },
+ { icon: 'fa-brain', num: stats.aiMemories, label: 'AI Moments', metric: 'aiMoments', title: 'Moments the AI remembers across every stream', recent: R.aiMoments },
+ { icon: 'fa-face-grin-squint', num: stats.emotes, label: 'Emotes', metric: '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` : '' },
{ 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 && rec.w > 0) ? `+${_fmtCount(rec.w)}${u} wk` : '';
+ // Rolling-window deltas: the small sub-line is "+N in 7d" (was the cryptic "+N wk"); the
+ // custom tooltip (data-tip, see _heroTooltip) spells out 24h / 7d / 30d and what the
+ // number means. Every chip with a series behind it is clickable → over-time chart.
+ const recSub = (rec, u = '') => (rec && rec.w > 0) ? `+${_fmtCount(rec.w)}${u} in 7d` : '';
const chip = (r) => {
const chip = (r) => {
if (r.html) return r.html; // pre-rendered chips (sparkline)
if (r.html) return r.html; // pre-rendered chips (sparkline)
- 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>`;
+ const tip = {
+ label: r.label, title: r.title || '', desc: r.desc || '',
+ recent: r.recent ? { d: r.recent.d, w: r.recent.w, m: r.recent.m, unit: r.unit || '' } : null,
+ metric: r.metric || null,
+ };
+ const clickable = !!r.metric;
+ return `<div class="hero-stat ${r.cls || ''} ${clickable ? 'hero-stat--clickable' : ''}" data-tip="${esc(JSON.stringify(tip))}" ${clickable ? `data-metric="${r.metric}" role="button" tabindex="0" aria-label="${esc(r.label)} — show over time"` : ''}><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}${clickable ? ' <i class="fa-solid fa-chart-line hero-stat-chart-hint"></i>' : ''}</span>${sub ? `<span class="hero-stat-sub">${sub}</span>` : ''}</div></div>`;
};
};
wrap.innerHTML = groups.filter(g => g.rows.length).map(g => `
wrap.innerHTML = groups.filter(g => g.rows.length).map(g => `
<div class="hero-stat-group">
<div class="hero-stat-group">
@@ -1301,6 +1309,101 @@ function renderHeroStats(stats) {
@@ -1301,6 +1309,101 @@ function renderHeroStats(stats) {
<div class="hero-stat-row">${g.rows.map(chip).join('')}</div>
<div class="hero-stat-row">${g.rows.map(chip).join('')}</div>
</div>`).join('');
</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));
+ _heroBindInteractions(wrap);
+}
+
+// ── Hero stat tooltips + click-through charts ───────────────────
+let _heroTipEl = null;
+function _heroTipShow(chip) {
+ let tip; try { tip = JSON.parse(chip.dataset.tip || 'null'); } catch { tip = null; }
+ if (!tip) return;
+ if (!_heroTipEl) { _heroTipEl = document.createElement('div'); _heroTipEl.className = 'hero-tip'; document.body.appendChild(_heroTipEl); }
+ const u = tip.recent?.unit || '';
+ _heroTipEl.innerHTML = `<div class="hero-tip-title">${esc(tip.label)}</div>
+ <div class="hero-tip-desc">${esc(tip.desc || tip.title)}</div>
+ ${tip.recent ? `<div class="hero-tip-recent">
+ <div><b>+${_fmtCount(tip.recent.d)}${u}</b><span>24 h</span></div>
+ <div><b>+${_fmtCount(tip.recent.w)}${u}</b><span>7 days</span></div>
+ <div><b>+${_fmtCount(tip.recent.m)}${u}</b><span>30 days</span></div>
+ </div>` : ''}
+ ${tip.metric ? '<div class="hero-tip-cta"><i class="fa-solid fa-chart-line"></i> Tap for the last 30 / 90 days</div>' : ''}`;
+ const r = chip.getBoundingClientRect();
+ _heroTipEl.style.left = '0px'; _heroTipEl.style.top = '0px';
+ _heroTipEl.classList.add('is-visible');
+ const w = _heroTipEl.offsetWidth, h = _heroTipEl.offsetHeight;
+ let left = Math.min(Math.max(8, r.left + r.width / 2 - w / 2), window.innerWidth - w - 8);
+ let top = r.top - h - 10;
+ if (top < 8) top = r.bottom + 10;
+ _heroTipEl.style.left = `${left}px`; _heroTipEl.style.top = `${top}px`;
+}
+function _heroTipHide() { if (_heroTipEl) _heroTipEl.classList.remove('is-visible'); }
+function _heroBindInteractions(wrap) {
+ const fine = window.matchMedia && window.matchMedia('(hover: hover)').matches;
+ wrap.querySelectorAll('.hero-stat[data-tip]').forEach(chip => {
+ if (fine) {
+ chip.addEventListener('mouseenter', () => _heroTipShow(chip));
+ chip.addEventListener('mouseleave', _heroTipHide);
+ }
+ chip.addEventListener('focus', () => _heroTipShow(chip));
+ chip.addEventListener('blur', _heroTipHide);
+ if (chip.dataset.metric) {
+ const open = () => { _heroTipHide(); _heroStatModal(chip); };
+ chip.addEventListener('click', open);
+ chip.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(); } });
+ }
+ });
+ window.addEventListener('scroll', _heroTipHide, { passive: true });
+}
+const _HERO_METRIC_ICON = { users: 'fa-users', anons: 'fa-user-secret', visitors: 'fa-user-plus', active: 'fa-fire', follows: 'fa-heart', messages: 'fa-comments', sessions: 'fa-tower-broadcast', streamers: 'fa-satellite-dish', vods: 'fa-film', clips: 'fa-scissors', hours: 'fa-clock', hoursWatched: 'fa-couch', aiMoments: 'fa-brain', vibes: 'fa-hand-holding-dollar', supporters: 'fa-hand-holding-heart', vibesBought: 'fa-cart-shopping', subs: 'fa-star', points: 'fa-coins', pointsSpent: 'fa-gift', redemptions: 'fa-gift', emotes: 'fa-face-grin-squint' };
+async function _heroStatModal(chip) {
+ let tip; try { tip = JSON.parse(chip.dataset.tip || '{}'); } catch { tip = {}; }
+ const metric = chip.dataset.metric;
+ document.querySelector('.hero-chart-overlay')?.remove();
+ const overlay = document.createElement('div');
+ overlay.className = 'hero-chart-overlay';
+ overlay.innerHTML = `<div class="hero-chart" role="dialog" aria-modal="true" aria-label="${esc(tip.label || metric)} over time">
+ <div class="hero-chart-head"><h3><i class="fa-solid ${_HERO_METRIC_ICON[metric] || 'fa-chart-line'}"></i> ${esc(tip.label || metric)}</h3><button class="hero-chart-close" aria-label="Close">×</button></div>
+ <p class="hero-chart-desc">${esc(tip.desc || tip.title || '')}</p>
+ <div class="hero-chart-ranges"><button data-days="7">7 days</button><button data-days="30" class="active">30 days</button><button data-days="90">90 days</button></div>
+ <div class="hero-chart-body"><div class="hero-chart-loading"><i class="fa-solid fa-circle-notch fa-spin"></i></div></div>
+ <div class="hero-chart-foot"></div>
+ </div>`;
+ document.body.appendChild(overlay);
+ const close = () => { overlay.remove(); document.removeEventListener('keydown', onKey); };
+ const onKey = (e) => { if (e.key === 'Escape') close(); };
+ document.addEventListener('keydown', onKey);
+ overlay.addEventListener('click', (e) => { if (e.target === overlay || e.target.closest('.hero-chart-close')) close(); });
+ const unit = tip.recent?.unit || '';
+ const load = async (days) => {
+ const body = overlay.querySelector('.hero-chart-body'), foot = overlay.querySelector('.hero-chart-foot');
+ body.innerHTML = '<div class="hero-chart-loading"><i class="fa-solid fa-circle-notch fa-spin"></i></div>';
+ try {
+ const d = await api(`/home/stats/series/${encodeURIComponent(metric)}?days=${days}`);
+ body.innerHTML = _heroChartSvg(d.points, unit);
+ const best = d.points.reduce((a, p) => (p.value > a.value ? p : a), { value: -1 });
+ foot.innerHTML = `<span><b>${_fmtCount(d.total)}${unit}</b> in the last ${d.days} days</span><span>avg <b>${_fmtCount(d.total / d.days)}${unit}</b> / day</span>${best.value > 0 ? `<span>best day <b>${_fmtCount(best.value)}${unit}</b> · ${esc(_heroDay(best.day))}</span>` : ''}`;
+ } catch { body.innerHTML = '<div class="hero-chart-loading">No data for this metric yet.</div>'; foot.innerHTML = ''; }
+ };
+ overlay.querySelectorAll('.hero-chart-ranges button').forEach(b => b.addEventListener('click', () => {
+ overlay.querySelectorAll('.hero-chart-ranges button').forEach(x => x.classList.toggle('active', x === b));
+ load(Number(b.dataset.days));
+ }));
+ load(30);
+}
+function _heroDay(iso) { try { return new Date(iso + 'T00:00:00Z').toLocaleDateString(undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' }); } catch { return iso; } }
+function _heroChartSvg(points, unit = '') {
+ const W = 720, H = 220, padL = 40, padR = 10, padT = 12, padB = 26;
+ const n = points.length;
+ const max = Math.max(1, ...points.map(p => p.value));
+ const x = (i) => padL + (i + 0.5) * ((W - padL - padR) / n);
+ const y = (v) => padT + (H - padT - padB) * (1 - v / max);
+ const bw = Math.max(2, ((W - padL - padR) / n) * (n > 40 ? 0.8 : 0.62));
+ const bars = points.map((p, i) => `<rect class="bar" x="${(x(i) - bw / 2).toFixed(1)}" y="${y(p.value).toFixed(1)}" width="${bw.toFixed(1)}" height="${(H - padB - y(p.value)).toFixed(1)}" rx="2" style="animation-delay:${(i * (0.35 / n)).toFixed(3)}s"><title>${esc(_heroDay(p.day))}: ${_fmtCount(p.value)}${unit}</title></rect>`).join('');
+ const line = points.map((p, i) => `${x(i).toFixed(1)},${y(p.value).toFixed(1)}`).join(' ');
+ const grid = [0, 0.5, 1].map(f => { const yy = y(max * f); return `<line class="grid" x1="${padL}" x2="${W - padR}" y1="${yy.toFixed(1)}" y2="${yy.toFixed(1)}"></line><text x="${padL - 6}" y="${(yy + 3).toFixed(1)}" text-anchor="end">${_fmtCount(max * f)}</text>`; }).join('');
+ const labelEvery = n > 40 ? Math.ceil(n / 8) : n > 10 ? Math.ceil(n / 6) : 1;
+ const labels = points.map((p, i) => (i % labelEvery === 0 || i === n - 1) ? `<text x="${x(i).toFixed(1)}" y="${H - 8}" text-anchor="middle">${esc(_heroDay(p.day))}</text>` : '').join('');
+ return `<svg class="hero-chart-svg" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" aria-hidden="true">${grid}${bars}${n > 1 ? `<polyline class="line" points="${line}"></polyline>` : ''}${labels}</svg>`;
}
}
// ── Hero floating thumbnail collage ─────────────────────────────
// ── Hero floating thumbnail collage ─────────────────────────────
@@ -4551,8 +4654,12 @@ function _aiTimelineMomentHTML(mom, vodId) {
@@ -4551,8 +4654,12 @@ function _aiTimelineMomentHTML(mom, vodId) {
try { tags = typeof mom.tags === 'string' ? JSON.parse(mom.tags) : (mom.tags || []); } catch { tags = []; }
try { tags = typeof mom.tags === 'string' ? JSON.parse(mom.tags) : (mom.tags || []); } catch { tags = []; }
const tagHTML = (Array.isArray(tags) ? tags : []).slice(0, 4)
const tagHTML = (Array.isArray(tags) ? tags : []).slice(0, 4)
.map(t => `<span class="ai-tl-tag">${esc(String(t))}</span>`).join('');
.map(t => `<span class="ai-tl-tag">${esc(String(t))}</span>`).join('');
- const thumb = mom.thumbnail_url
- ? `<img class="ai-tl-moment-thumb" src="${esc(mom.thumbnail_url)}" alt="" loading="lazy" onerror="this.remove()">`
+ // Older moments point at the rotating live thumbnail, which is gone (the server answers
+ // those with a placeholder pixel, so onerror never fires and a white box appears).
+ // Newer moments carry their own persisted frame under /data/ai-moments/.
+ const thumbUrl = (mom.thumbnail_url && !/\/api\/thumbnails\/stream-/.test(mom.thumbnail_url)) ? mom.thumbnail_url : '';
+ const thumb = thumbUrl
+ ? `<img class="ai-tl-moment-thumb" src="${esc(thumbUrl)}" alt="" loading="lazy" onerror="this.remove()">`
: '';
: '';
const jump = vodId
const jump = vodId
? `<a class="ai-tl-stamp" href="/vod/${vodId}?t=${off}" onclick="return handleLinkClick(event, '/vod/${vodId}?t=${off}')" title="Watch this moment"><i class="fa-solid fa-play"></i> ${stamp}</a>`
? `<a class="ai-tl-stamp" href="/vod/${vodId}?t=${off}" onclick="return handleLinkClick(event, '/vod/${vodId}?t=${off}')" title="Watch this moment"><i class="fa-solid fa-play"></i> ${stamp}</a>`
@@ -4623,6 +4730,32 @@ function _aiTimelineSessionHTML(s) {
@@ -4623,6 +4730,32 @@ function _aiTimelineSessionHTML(s) {
<div class="ai-tl-transcript" hidden></div>
<div class="ai-tl-transcript" hidden></div>
</div>`;
</div>`;
}
}
+// Map the audio model's many labels onto a few readable families with an icon. Returns
+// null for labels that are speech-like or pure noise — those are not "sounds" worth a chip.
+const _AI_SOUND_FAMILIES = [
+ { key: 'rain', icon: 'fa-cloud-rain', label: 'Rain', re: /rain|drizzle|water|drip|splash|stream|river/i },
+ { key: 'thunder', icon: 'fa-bolt', label: 'Thunder', re: /thunder/i },
+ { key: 'wind', icon: 'fa-wind', label: 'Wind', re: /wind|breeze|rustl/i },
+ { key: 'music', icon: 'fa-music', label: 'Music', re: /music|song|guitar|piano|drum|synth|beat|melody|singing|choir|hip hop|rock|jazz|techno|electronic/i },
+ { key: 'laugh', icon: 'fa-face-laugh', label: 'Laughter', re: /laugh|giggle|chuckle|snicker/i },
+ { key: 'explosion', icon: 'fa-burst', label: 'Explosion', re: /explos|gunshot|gunfire|blast|boom|artillery|fireworks/i },
+ { key: 'vehicle', icon: 'fa-car', label: 'Vehicle', re: /vehicle|car\b|engine|motor|truck|bus|traffic|boat|train|aircraft|helicopter|siren/i },
+ { key: 'animal', icon: 'fa-paw', label: 'Animal', re: /dog|cat|bird|animal|bark|meow|chirp|insect|cricket|goose|duck|cow|horse/i },
+ { key: 'keys', icon: 'fa-keyboard', label: 'Clicks & keys', re: /typing|keyboard|click|mouse|keys|jangl|tick|tap/i },
+ { key: 'alarm', icon: 'fa-bell', label: 'Alarm / ding', re: /alarm|beep|ding|bell|ring|notification|chime/i },
+ { key: 'crowd', icon: 'fa-people-group', label: 'Crowd', re: /crowd|applause|cheer|chatter|hubbub/i },
+ { key: 'kitchen', icon: 'fa-utensils', label: 'Kitchen', re: /siz{1,2}l|fry|boil|cutlery|dish|kitchen|microwave|blender|chop/i },
+ { key: 'tools', icon: 'fa-screwdriver-wrench', label: 'Tools', re: /drill|hammer|saw|tool|grind|sand|screw|crackl|rattle|clank|clink|chink|metal/i },
+ { key: 'breath', icon: 'fa-lungs', label: 'Breath / sigh', re: /sigh|breath|gasp|yawn|cough|sneeze|snif/i },
+ { key: 'door', icon: 'fa-door-open', label: 'Doors & steps', re: /door|footstep|walk|knock|creak/i },
+ { key: 'game', icon: 'fa-gamepad', label: 'Game audio', re: /video game|game|sound effect|whoosh|swoosh|zap|arcade/i },
+];
+function _aiSoundFamily(label) {
+ const l = String(label || '').trim();
+ if (!l || /^(speech|conversation|narration|monologue|male speech|female speech|child speech|silence|noise|white noise|static|hum|inside|outside|room)/i.test(l)) return null;
+ for (const f of _AI_SOUND_FAMILIES) if (f.re.test(l)) return { key: f.key, icon: f.icon, label: f.label };
+ return { key: 'other', icon: 'fa-volume-high', label: l.length > 28 ? l.slice(0, 26) + '…' : l };
+}
function _fmtCount(n) { n = Number(n) || 0; return n >= 1000 ? (n / 1000).toFixed(n >= 10000 ? 0 : 1).replace(/\.0$/, '') + 'k' : String(n); }
function _fmtCount(n) { n = Number(n) || 0; return n >= 1000 ? (n / 1000).toFixed(n >= 10000 ? 0 : 1).replace(/\.0$/, '') + 'k' : String(n); }
// Expand/collapse a session's moments, building the moment DOM only on first expand.
// Expand/collapse a session's moments, building the moment DOM only on first expand.
@@ -4655,26 +4788,48 @@ async function _aiTlToggleTranscript(btn) {
@@ -4655,26 +4788,48 @@ async function _aiTlToggleTranscript(btn) {
const data = await api(`/chat-ai/transcript/${session.dataset.sid}`);
const data = await api(`/chat-ai/transcript/${session.dataset.sid}`);
const segs = data.segments || [];
const segs = data.segments || [];
const vod = data.vodId;
const vod = data.vodId;
- // Interleave spoken lines with detected non-speech sounds so the timeline reads
- // as everything that happened audibly, in order — not just the words.
+ // Speech and detected sounds are different things: speech reads as lines, sounds
+ // as a quiet strip of chips between them (grouped, deduplicated, family-merged —
+ // "Rain / Raindrop / Rain on surface / Water" is one "Rain" chip, not four rows).
const rows = [
const rows = [
- ...segs.map(sg => ({ t: sg.start || 0, kind: 'speech', text: sg.text || '' })),
- ...(data.events || []).map(e => ({
- t: e.start_sec || 0, kind: 'sound',
- text: e.label + (e.confidence != null ? ` (${Number(e.confidence).toFixed(2)})` : ''),
- })),
+ ...segs.map(sg => ({ t: sg.start || 0, kind: 'speech', text: String(sg.text || '').replace(/^\s*(?:>>|--?|•)\s*/, '').trim() })).filter(r => r.text),
+ ...(data.events || []).map(e => ({ t: e.start_sec || 0, kind: 'sound', label: String(e.label || ''), conf: Number(e.confidence) || 0 })),
].sort((a, b) => a.t - b.t);
].sort((a, b) => a.t - b.t);
if (!rows.length) { box.innerHTML = '<p class="muted" style="padding:6px">No transcript available.</p>'; return; }
if (!rows.length) { box.innerHTML = '<p class="muted" style="padding:6px">No transcript available.</p>'; return; }
- const cov = data.coverageSec
- ? `<div class="muted" style="padding:4px 6px;font-size:.8rem">${_aiTimeFmt(data.coverageSec)} of speech transcribed</div>` : '';
- box.innerHTML = cov + rows.map(r => {
- const stamp = _aiTimeFmt(r.t);
- const jump = vod ? `<a class="ai-tl-ts" href="/vod/${vod}?t=${Math.floor(r.t)}" onclick="return handleLinkClick(event, '/vod/${vod}?t=${Math.floor(r.t)}')">${stamp}</a>` : `<span class="ai-tl-ts">${stamp}</span>`;
- const body = r.kind === 'sound'
- ? `<span class="ai-tl-tr-text muted"><i class="fa-solid fa-volume-high"></i> ${esc(r.text)}</span>`
- : `<span class="ai-tl-tr-text">${esc(r.text)}</span>`;
- return `<div class="ai-tl-tr-line">${jump}${body}</div>`;
+ const blocks = [];
+ for (const r of rows) {
+ if (r.kind === 'speech') { blocks.push(r); continue; }
+ const fam = _aiSoundFamily(r.label);
+ if (!fam) continue; // speech-like / noise labels are not "sounds"
+ if (r.conf && r.conf < 0.3) continue; // too unsure to show
+ const last = blocks[blocks.length - 1];
+ if (last && last.kind === 'sounds') {
+ const hit = last.items.find(i => i.key === fam.key);
+ if (hit) { hit.n++; hit.conf = Math.max(hit.conf, r.conf); } else last.items.push({ ...fam, n: 1, conf: r.conf, t: r.t });
+ last.tEnd = r.t;
+ } else blocks.push({ kind: 'sounds', t: r.t, tEnd: r.t, items: [{ ...fam, n: 1, conf: r.conf, t: r.t }] });
+ }
+ const nSpeech = blocks.filter(b => b.kind === 'speech').length, nSound = blocks.filter(b => b.kind === 'sounds').length;
+ const cov = data.coverageSec ? `${_aiTimeFmt(data.coverageSec)} of speech transcribed` : '';
+ const jumpFor = (t, cls = 'ai-tl-ts') => vod ? `<a class="${cls}" href="/vod/${vod}?t=${Math.floor(t)}" onclick="return handleLinkClick(event, '/vod/${vod}?t=${Math.floor(t)}')" title="Watch from here">${_aiTimeFmt(t)}</a>` : `<span class="${cls}">${_aiTimeFmt(t)}</span>`;
+ const toolbar = `<div class="ai-tl-tr-bar">
+ <div class="ai-tl-tr-filter" role="tablist">
+ <button type="button" class="active" data-f="all">All</button>
+ <button type="button" data-f="speech"><i class="fa-solid fa-comment"></i> Speech <span>${nSpeech}</span></button>
+ <button type="button" data-f="sounds"><i class="fa-solid fa-wave-square"></i> Sounds <span>${nSound}</span></button>
+ </div>
+ <span class="ai-tl-tr-hint">${cov ? cov + ' · ' : ''}sounds are detected by the audio model — hover a chip for its confidence</span>
+ </div>`;
+ box.innerHTML = toolbar + blocks.map(b => {
+ if (b.kind === 'speech') return `<div class="ai-tl-tr-line ai-tl-tr-speech">${jumpFor(b.t)}<span class="ai-tl-tr-text">${esc(b.text)}</span></div>`;
+ const span = b.tEnd > b.t + 2 ? `${_aiTimeFmt(b.t)}–${_aiTimeFmt(b.tEnd)}` : _aiTimeFmt(b.t);
+ const chips = b.items.sort((x, y) => y.n - x.n).map(i => `<span class="ai-tl-sound ai-tl-sound--${i.key}" title="${esc(i.label)} · confidence ${(i.conf * 100).toFixed(0)}%"><i class="fa-solid ${i.icon}"></i>${esc(i.label)}${i.n > 1 ? `<b>×${i.n}</b>` : ''}</span>`).join('');
+ return `<div class="ai-tl-tr-line ai-tl-tr-sounds">${jumpFor(b.t, 'ai-tl-ts ai-tl-ts--sound')}<div class="ai-tl-sound-strip" title="${esc(span)}">${chips}</div></div>`;
}).join('');
}).join('');
+ box.querySelectorAll('.ai-tl-tr-filter button').forEach(btn => btn.addEventListener('click', () => {
+ box.querySelectorAll('.ai-tl-tr-filter button').forEach(b => b.classList.toggle('active', b === btn));
+ box.dataset.filter = btn.dataset.f;
+ }));
} catch { box.innerHTML = '<p class="muted" style="padding:6px">Couldn\'t load transcript.</p>'; box.dataset.built = ''; }
} catch { box.innerHTML = '<p class="muted" style="padding:6px">Couldn\'t load transcript.</p>'; box.dataset.built = ''; }
}
}
}
}
@@ -37,6 +37,36 @@ function captureMemoryNow(stream, { allowFfmpeg = true, reason = 'manual' } = {}
@@ -37,6 +37,36 @@ function captureMemoryNow(stream, { allowFfmpeg = true, reason = 'manual' } = {}
return p;
return p;
}
}
+// ── Moment frames ────────────────────────────────────────────
+// The live thumbnail a moment used to point at rotates every few seconds and is deleted
+// within the hour, so every moment in the timeline ended up showing the placeholder
+// pixel (a white box). Keep a small JPEG of the analysed frame per moment instead,
+// under data/ai-moments/<streamId>/<offset>.jpg (served at /data/ai-moments/…, ~15 KB).
+const MOMENTS_DIR = require('path').resolve(process.env.AI_MOMENTS_PATH || './data/ai-moments');
+async function _persistMomentFrame(image, streamId, offset) {
+ if (!image) return null;
+ try {
+ const fs = require('fs'), path = require('path');
+ const sharp = require('sharp');
+ const dir = path.join(MOMENTS_DIR, String(streamId));
+ fs.mkdirSync(dir, { recursive: true });
+ const file = `${Math.max(0, Math.round(offset))}.jpg`;
+ const input = Buffer.isBuffer(image) ? image : fs.readFileSync(image);
+ await sharp(input).resize({ width: 320, withoutEnlargement: true }).jpeg({ quality: 72 }).toFile(path.join(dir, file));
+ return `/data/ai-moments/${streamId}/${file}`;
+ } catch (e) {
+ console.warn('[AI-See] moment frame not saved:', e.message);
+ return null;
+ }
+}
+// A live thumbnail URL (/api/thumbnails/stream-…) is never worth storing — it will be gone
+// by the time anyone looks; Media-hosted VOD thumbnails are stable and fine.
+function _stableThumb(url) {
+ const u = String(url || '');
+ if (!u || /\/api\/thumbnails\/stream-/.test(u)) return null;
+ return u;
+}
+
async function _analyzeOne(stream, { allowFfmpeg = true, reason = 'periodic' } = {}) {
async function _analyzeOne(stream, { allowFfmpeg = true, reason = 'periodic' } = {}) {
let image = null;
let image = null;
if (allowFfmpeg) { try { if (vision && vision.captureFrame) image = await vision.captureFrame(stream); } catch { /* */ } }
if (allowFfmpeg) { try { if (vision && vision.captureFrame) image = await vision.captureFrame(stream); } catch { /* */ } }
@@ -109,7 +139,7 @@ async function _analyzeOne(stream, { allowFfmpeg = true, reason = 'periodic' } =
@@ -109,7 +139,7 @@ async function _analyzeOne(stream, { allowFfmpeg = true, reason = 'periodic' } =
try {
try {
db.addStreamMemory({
db.addStreamMemory({
stream_id: stream.id, user_id: stream.user_id, offset_seconds: offset,
stream_id: stream.id, user_id: stream.user_id, offset_seconds: offset,
- description: memDesc, tags: r.tags, thumbnail_url: stream.thumbnail_url || null,
+ description: memDesc, tags: r.tags, thumbnail_url: await _persistMomentFrame(image, stream.id, offset) || _stableThumb(stream.thumbnail_url),
transcript_json: heardSegments,
transcript_json: heardSegments,
});
});
// Roll this stream's memories into an overview — but only RE-summarize when
// Roll this stream's memories into an overview — but only RE-summarize when
@@ -3867,6 +3867,65 @@ function getActiveGoalsForUsers(userIds) {
@@ -3867,6 +3867,65 @@ function getActiveGoalsForUsers(userIds) {
return out;
return out;
}
}
+/** ISO cutoff for Vibes stats (setting `stats_vibes_reset_at`), or the epoch when unset. */
+function vibesStatsSince() {
+ try {
+ const v = String(getSetting('stats_vibes_reset_at') || '').trim();
+ if (v && !isNaN(Date.parse(v))) return new Date(v).toISOString().replace('T', ' ').slice(0, 19);
+ } catch { /* */ }
+ return '1970-01-01 00:00:00';
+}
+
+// ── Home stat series (click a hero stat → "over time" chart) ────────────────
+// One registry entry per metric: the table, its timestamp column, what to aggregate and
+// an optional WHERE. `days` buckets are computed in SQL by date(); missing days are
+// filled with 0 so charts never skip a day.
+const HOME_SERIES = {
+ users: { table: 'users', ts: 'created_at', agg: 'COUNT(*)', where: 'COALESCE(is_banned, 0) = 0' },
+ anons: { table: 'anon_ip_mappings', ts: 'created_at', agg: 'COUNT(*)' },
+ visitors: { table: 'anon_ip_mappings', ts: 'created_at', agg: 'COUNT(*)' },
+ follows: { table: 'follows', ts: 'created_at', agg: 'COUNT(*)' },
+ messages: { table: 'chat_messages', ts: 'timestamp', agg: 'COUNT(*)', where: 'COALESCE(is_deleted, 0) = 0' },
+ active: { table: 'chat_messages', ts: 'timestamp', agg: "COUNT(DISTINCT COALESCE('u' || user_id, 'a' || anon_id, 'r' || source_platform || '|' || username))", where: 'COALESCE(is_deleted, 0) = 0' },
+ sessions: { table: 'streams', ts: 'created_at', agg: 'COUNT(*)' },
+ streamers: { table: 'streams', ts: 'created_at', agg: 'COUNT(DISTINCT user_id)' },
+ vods: { table: 'vods', ts: 'created_at', agg: 'COUNT(*)', where: 'is_public = 1 AND COALESCE(is_recording, 0) = 0' },
+ clips: { table: 'clips', ts: 'created_at', agg: 'COUNT(*)', where: 'COALESCE(is_public, 1) = 1' },
+ hours: { table: 'vods', ts: 'created_at', agg: 'COALESCE(SUM(duration_seconds), 0) / 3600.0', where: 'COALESCE(is_recording, 0) = 0' },
+ hoursWatched:{ table: 'watch_time', ts: 'created_at', agg: 'COALESCE(SUM(minutes_watched), 0) / 60.0' },
+ aiMoments: { table: 'stream_memories', ts: 'created_at', agg: 'COUNT(*)' },
+ vibes: { table: 'transactions', ts: 'created_at', agg: 'COALESCE(SUM(amount), 0)', where: "type = 'donation'", vibesReset: true },
+ supporters: { table: 'transactions', ts: 'created_at', agg: 'COUNT(DISTINCT from_user_id)', where: "type = 'donation' AND from_user_id IS NOT NULL", vibesReset: true },
+ vibesBought: { table: 'payment_orders', ts: 'updated_at', agg: 'COALESCE(SUM(bucks), 0)', where: "kind = 'bucks' AND status = 'credited'" },
+ subs: { table: 'subscriptions', ts: 'created_at', agg: 'COUNT(*)' },
+ points: { table: 'coin_transactions', ts: 'created_at', agg: 'COALESCE(SUM(amount), 0)', where: 'amount > 0' },
+ pointsSpent: { table: 'coin_transactions', ts: 'created_at', agg: 'COALESCE(-SUM(amount), 0)', where: 'amount < 0' },
+ redemptions: { table: 'coin_redemptions', ts: 'created_at', agg: 'COUNT(*)', where: "status NOT IN ('rejected', 'refunded')" },
+ emotes: { table: 'emotes', ts: 'created_at', agg: 'COUNT(*)' },
+};
+const HOME_SERIES_KEYS = Object.keys(HOME_SERIES);
+
+function getHomeStatSeries(metric, days = 30) {
+ const def = HOME_SERIES[metric];
+ if (!def) return null;
+ days = Math.max(1, Math.min(365, parseInt(days, 10) || 30));
+ const where = [`${def.ts} >= datetime('now', ?)`];
+ if (def.where) where.push(def.where);
+ if (def.vibesReset) where.push(`${def.ts} >= '${vibesStatsSince()}'`);
+ let rows = [];
+ try {
+ rows = all(`SELECT date(${def.ts}) AS day, ${def.agg} AS value FROM ${def.table} WHERE ${where.join(' AND ')} GROUP BY day ORDER BY day ASC`, [`-${days - 1} days`]);
+ } catch { rows = []; }
+ const byDay = new Map(rows.map(r => [r.day, Number(r.value) || 0]));
+ const points = [];
+ for (let i = days - 1; i >= 0; i--) {
+ const day = new Date(Date.now() - i * 86400000).toISOString().slice(0, 10);
+ points.push({ day, value: Number((byDay.get(day) || 0).toFixed(2)) });
+ }
+ const total = Number(points.reduce((a, p) => a + p.value, 0).toFixed(2));
+ return { metric, days, points, total, peak: Math.max(0, ...points.map(p => p.value)) };
+}
+
function getHomeStats() {
function getHomeStats() {
const now = Date.now();
const now = Date.now();
if (_homeStatsCache && (now - _homeStatsCacheAt) < _HOME_STATS_TTL) return _homeStatsCache;
if (_homeStatsCache && (now - _homeStatsCacheAt) < _HOME_STATS_TTL) return _homeStatsCache;
@@ -3878,6 +3937,9 @@ function getHomeStats() {
@@ -3878,6 +3937,9 @@ function getHomeStats() {
function _computeHomeStats() {
function _computeHomeStats() {
// Each stat is isolated so a missing table / column can never blank the whole hero.
// Each stat is isolated so a missing table / column can never blank the whole hero.
const c = (sql, p = []) => { try { return get(sql, p)?.count || 0; } catch { return 0; } };
const c = (sql, p = []) => { try { return get(sql, p)?.count || 0; } catch { return 0; } };
+ // Vibes tipped before this instant were test money (site setting `stats_vibes_reset_at`,
+ // ISO timestamp). Everything Vibes-related on the hero starts counting from it.
+ const vibesSince = vibesStatsSince();
// Rolling day/week/month counts for a table by its timestamp column.
// Rolling day/week/month counts for a table by its timestamp column.
const winCount = (table, col, extra = '') => {
const winCount = (table, col, extra = '') => {
const q = (w) => c(`SELECT COUNT(*) AS count FROM ${table} WHERE ${col} >= datetime('now', ?)${extra ? ' AND ' + extra : ''}`, [w]);
const q = (w) => c(`SELECT COUNT(*) AS count FROM ${table} WHERE ${col} >= datetime('now', ?)${extra ? ' AND ' + extra : ''}`, [w]);
@@ -3896,7 +3958,7 @@ function _computeHomeStats() {
@@ -3896,7 +3958,7 @@ function _computeHomeStats() {
// Community time actually spent watching (watch-time heartbeats → hours).
// 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),
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).
// Vibes tipped between people (donation ledger; bit-style, 100 = $1).
- vibesTipped: c(`SELECT COALESCE(SUM(amount), 0) AS count FROM transactions WHERE type = 'donation'`),
+ vibesTipped: c(`SELECT COALESCE(SUM(amount), 0) AS count FROM transactions WHERE type = 'donation' AND created_at >= ?`, [vibesSince]),
// Live channel subscriptions.
// 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)`),
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).
// Channel points earned by viewers across every channel (watch/chat/follow bonuses).
@@ -3906,7 +3968,7 @@ function _computeHomeStats() {
@@ -3906,7 +3968,7 @@ function _computeHomeStats() {
// Reward redemptions that stuck (not rejected / refunded).
// Reward redemptions that stuck (not rejected / refunded).
redemptions: c(`SELECT COUNT(*) AS count FROM coin_redemptions WHERE status NOT IN ('rejected', 'refunded')`),
redemptions: c(`SELECT COUNT(*) AS count FROM coin_redemptions WHERE status NOT IN ('rejected', 'refunded')`),
// Distinct people who have tipped Vibes to someone.
// Distinct people who have tipped Vibes to someone.
- supporters: c(`SELECT COUNT(DISTINCT from_user_id) AS count FROM transactions WHERE type = 'donation' AND from_user_id IS NOT NULL`),
+ supporters: c(`SELECT COUNT(DISTINCT from_user_id) AS count FROM transactions WHERE type = 'donation' AND from_user_id IS NOT NULL AND created_at >= ?`, [vibesSince]),
// Vibes bought with real money (credited purchase orders, any provider).
// Vibes bought with real money (credited purchase orders, any provider).
vibesBought: c(`SELECT COALESCE(SUM(bucks), 0) AS count FROM payment_orders WHERE kind = 'bucks' AND status = 'credited'`),
vibesBought: c(`SELECT COALESCE(SUM(bucks), 0) AS count FROM payment_orders WHERE kind = 'bucks' AND status = 'credited'`),
// Donation goals: currently running + ever reached.
// Donation goals: currently running + ever reached.
@@ -3952,7 +4014,7 @@ function _computeHomeStats() {
@@ -3952,7 +4014,7 @@ 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'"),
+ vibes: winSum('transactions', 'amount', 'created_at', `type = 'donation' AND created_at >= '${vibesSince}'`),
points: winSum('coin_transactions', 'amount', 'created_at', 'amount > 0'),
points: winSum('coin_transactions', 'amount', 'created_at', 'amount > 0'),
pointsSpent: winSum('coin_transactions', '-amount', 'created_at', 'amount < 0'),
pointsSpent: winSum('coin_transactions', '-amount', 'created_at', 'amount < 0'),
redemptions: winCount('coin_redemptions', 'created_at', "status NOT IN ('rejected', 'refunded')"),
redemptions: winCount('coin_redemptions', 'created_at', "status NOT IN ('rejected', 'refunded')"),
@@ -7917,6 +7979,7 @@ function addToDonationGoal(id, amount) {
@@ -7917,6 +7979,7 @@ function addToDonationGoal(id, amount) {
}
}
module.exports = {
module.exports = {
+ getHomeStatSeries, HOME_SERIES_KEYS, vibesStatsSince, _computeHomeStats,
getVodAiState, getClipAiState,
getVodAiState, getClipAiState,
scheduleClipNotifyState, bumpClipNotifyNowState, markClipNotifiedState, getDueClipNotifies,
scheduleClipNotifyState, bumpClipNotifyNowState, markClipNotifiedState, getDueClipNotifies,
getDb, initDb, run, get, all, close,
getDb, initDb, run, get, all, close,
@@ -170,6 +170,14 @@ async function _mediaStatsCached() {
@@ -170,6 +170,14 @@ async function _mediaStatsCached() {
return _mediaStats.data;
return _mediaStats.data;
}
}
+// GET /api/home/stats/series/:metric?days=30 — daily values behind a hero stat.
+router.get('/stats/series/:metric', (req, res) => {
+ const series = db.getHomeStatSeries(String(req.params.metric), req.query.days);
+ if (!series) return res.status(404).json({ error: 'Unknown metric', metrics: db.HOME_SERIES_KEYS });
+ res.set('Cache-Control', 'public, max-age=120');
+ res.json(series);
+});
+
async function heroStats() {
async function heroStats() {
const stats = { ...db.getHomeStats() };
const stats = { ...db.getHomeStats() };
const m = await _mediaStatsCached();
const m = await _mediaStatsCached();
@@ -462,6 +462,11 @@ app.use(express.static(path.join(__dirname, '../public'), { setHeaders: (res, fi
@@ -462,6 +462,11 @@ app.use(express.static(path.join(__dirname, '../public'), { setHeaders: (res, fi
// Serve locally-cached song-request media files (media player page)
// Serve locally-cached song-request media files (media player page)
app.use('/media', express.static(path.resolve('./data/media')));
app.use('/media', express.static(path.resolve('./data/media')));
+// AI moment frames (one small JPEG per stream memory, see server/ai/stream-memory-job.js)
+app.use('/data/ai-moments', express.static(path.resolve(process.env.AI_MOMENTS_PATH || './data/ai-moments'), {
+ maxAge: '30d', immutable: true, index: false, dotfiles: 'deny',
+}));
+
// Arena portraits (AI-generated fighting-game character art, see server/arena)
// Arena portraits (AI-generated fighting-game character art, see server/arena)
app.use('/data/arena', express.static(path.resolve(process.env.ARENA_IMAGE_PATH || './data/arena'), {
app.use('/data/arena', express.static(path.resolve(process.env.ARENA_IMAGE_PATH || './data/arena'), {
maxAge: '7d', immutable: true, index: false, dotfiles: 'deny',
maxAge: '7d', immutable: true, index: false, dotfiles: 'deny',
new file mode 100644
new file mode 100644
@@ -0,0 +1,61 @@
@@ -0,0 +1,61 @@
+'use strict';
+
+// Home hero stats: the per-metric daily series behind the click-through charts, and the
+// Vibes reset cutoff (`stats_vibes_reset_at`) that hides test money from the counters.
+
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+process.env.DB_PATH = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'ov-stats-')), 'live.db');
+const db = require('../server/db/database');
+db.initDb();
+
+const mk = (u, key) => Number(db.createUser({ username: u, email: `${u}@x`, password_hash: 'x', display_name: u, stream_key: key }).lastInsertRowid);
+const a = mk('alice', 'a'.repeat(32)), b = mk('bob', 'b'.repeat(32));
+db.run("UPDATE users SET created_at = datetime('now', '-3 days') WHERE id = ?", [a]);
+db.run("UPDATE users SET created_at = datetime('now', '-1 days') WHERE id = ?", [b]);
+
+// ── series: registry, zero-filled days, totals ──
+assert.ok(db.HOME_SERIES_KEYS.includes('users') && db.HOME_SERIES_KEYS.includes('vibes'));
+assert.strictEqual(db.getHomeStatSeries('nope'), null, 'unknown metric → null');
+const users7 = db.getHomeStatSeries('users', 7);
+assert.strictEqual(users7.points.length, 7, 'one point per day, zero-filled');
+assert.strictEqual(users7.total, 2);
+assert.strictEqual(users7.points[6 - 3].value, 1, 'alice 3 days ago');
+assert.strictEqual(users7.points[6 - 1].value, 1, 'bob yesterday');
+assert.strictEqual(users7.points[6].value, 0, 'nobody today');
+assert.strictEqual(db.getHomeStatSeries('users', 2).total, 1, 'window shrinks the total');
+assert.strictEqual(db.getHomeStatSeries('users', 9999).days, 365, 'days are clamped');
+console.log('✅ daily series are zero-filled and windowed');
+
+// ── vibes: test money before the reset never counts ──
+db.run("INSERT INTO transactions (from_user_id, to_user_id, amount, type, status, created_at) VALUES (?, ?, 500, 'donation', 'completed', datetime('now', '-10 days'))", [a, b]);
+db.run("INSERT INTO transactions (from_user_id, to_user_id, amount, type, status, created_at) VALUES (?, ?, 200, 'donation', 'completed', datetime('now', '-2 days'))", [b, a]);
+db.run("INSERT INTO transactions (from_user_id, to_user_id, amount, type, status, created_at) VALUES (?, ?, 50, 'donation', 'completed', datetime('now', '-1 hours'))", [b, a]);
+
+let stats = db.getHomeStats();
+assert.strictEqual(stats.vibesTipped, 750, 'no reset → everything counts');
+assert.strictEqual(stats.supporters, 2);
+assert.strictEqual(db.getHomeStatSeries('vibes', 30).total, 750);
+
+db.setSetting('stats_vibes_reset_at', new Date(Date.now() - 36 * 3600 * 1000).toISOString()); // 36 h ago
+// getHomeStats is cached for a while — compute fresh through the internal path.
+stats = db._computeHomeStats ? db._computeHomeStats() : null;
+if (!stats) { // fall back: the cache TTL keeps the old value; verify via the series + cutoff helper instead
+ assert.ok(db.vibesStatsSince() > '2000-01-01', 'cutoff parsed');
+} else {
+ assert.strictEqual(stats.vibesTipped, 50, 'only vibes after the reset count');
+ assert.strictEqual(stats.supporters, 1);
+ assert.strictEqual(stats.recent.vibes.d, 50);
+ assert.strictEqual(stats.recent.vibes.m, 50, 'the 30-day window is also cut at the reset');
+}
+assert.strictEqual(db.getHomeStatSeries('vibes', 30).total, 50, 'series honours the reset');
+assert.strictEqual(db.getHomeStatSeries('supporters', 30).total, 1);
+db.setSetting('stats_vibes_reset_at', 'garbage');
+assert.strictEqual(db.vibesStatsSince(), '1970-01-01 00:00:00', 'an unparseable reset value is ignored, not fatal');
+console.log('✅ Vibes reset cutoff applies to counters, deltas and series');
+
+console.log('\n✅ All home-stats tests passed');
+process.exit(0);