openvibers/openvibe.live Commits

Home stats: tooltips, click-through charts, clearer windows; AI timeline: persistent moment frames + speech/sound layout; Vibes reset

OP OpenVibers committed 29 Aug 2026 · 444f415e0c · 8 files, +452 −51

Home hero stats
- Every stat chip gets a custom tooltip (what it means + 24h / 7d / 30d deltas) instead
  of a native title, and opens an over-time chart (7 / 30 / 90 days, daily bars +
  line, total / avg / best day) backed by GET /api/home/stats/series/:metric — one
  registry entry per metric in database.js, zero-filled days.
- Labels say their window: "Active · 7d", "Visitors · 7d"; the sub-line reads
  "+N in 7d" instead of "+N wk". Descriptions explain who counts as active / a visitor.
- Mobile: icons are shown again (smaller) instead of hidden; the fixed nav drops its
  backdrop blur on phones, the scroll→nav handler is rAF-throttled, stat groups use
  content-visibility, hover transforms are off under 720px.
- Vibes tipped / supporters / their deltas and series start at the site setting
  `stats_vibes_reset_at` (test money before it no longer counts).

AI timeline (channel page)
- Moments keep their own 320px JPEG (data/ai-moments/<stream>/<offset>.jpg) instead of
  pointing at the rotating live thumbnail, which was deleted within the hour and
  answered with a placeholder pixel — the white boxes. Old moments with such URLs no
  longer render a thumbnail at all.
- The transcript separates speech lines from detected sounds: consecutive sound events
  collapse into one strip of family chips (Rain/Raindrop/Water → Rain ×3, with icons and
  confidence on hover), low-confidence events are dropped, and an All / Speech / Sounds
  filter sits above the list.

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

Changes

ddiff --git a/public/css/style.css b/public/css/style.css
iindex a597b069..ef6854ab 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -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; } }
ddiff --git a/public/index.html b/public/index.html
iindex 92ff7477..166a1164 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -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>
ddiff --git a/public/js/app.js b/public/js/app.js
iindex 302c8a25..5cc027dd 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -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">&times;</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 = ''; }
}
}
ddiff --git a/server/ai/stream-memory-job.js b/server/ai/stream-memory-job.js
iindex c0a0311c..012a19e0 100644
---- a/server/ai/stream-memory-job.js
++++ b/server/ai/stream-memory-job.js
@@@ -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
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 43d102f2..1702f0a9 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -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,
ddiff --git a/server/home/routes.js b/server/home/routes.js
iindex 7c6b76b2..710d2183 100644
---- a/server/home/routes.js
++++ b/server/home/routes.js
@@@ -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();
ddiff --git a/server/index.js b/server/index.js
iindex 98194b68..3f477321 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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',
ddiff --git a/test/home-stats-series.test.js b/test/home-stats-series.test.js
nnew file mode 100644
iindex 00000000..fc6a51e7
---- /dev/null
++++ b/test/home-stats-series.test.js
@@@ -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);