openvibers/openvibe.live Commits

Home: pulse rail, viewer sparkline, leaders, digest, AI moments, goal bars, ship line

OP OpenVibers committed 27 Aug 2026 · 0da83f3340 · 8 files, +385 −4

All seven homepage upgrades in one pass:
- "Happening Now" rail: site-wide donation goals nearest completion with
  progress bars, the latest tip and newest follow, and weekly leaderboard
  teasers (top supporters by the donation ledger, top point earners) —
  served by a cached /api/home/pulse.
- 24h viewer sparkline in the hero's "Right now" island, fed by a new
  5-minute viewer_samples sampler (7-day retention).
- "While you were away" digest for returning logged-in users: followed
  channels live right now + those that streamed since the last visit
  (client-remembered timestamp, /api/home/digest).
- AI Moments showcase: a horizontal row of the AI's clipped moments
  linking straight into the VOD at the right offset.
- Recently Online cards show each streamer's nearest active goal as a
  compact progress bar (batch-enriched in /recently-online).
- The hero carries a "shipped <ago>: <subject>" one-liner from the newest
  git commit, linking to /updates.

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 0e4cfbbe..a2ff7fc4 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -2198,6 +2198,84 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
text-shadow: 0 1px 8px rgba(0, 0, 0, 0.5);
}
.hero-stat-row { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; }
++.hero-stat--spark { flex-direction: column; gap: 2px; padding: 7px 12px; color: var(--accent); }
++.hero-stat--spark svg { display: block; opacity: 0.9; }
++
++/* Newest shipped commit — hero one-liner */
++.hero-latest {
++ display: inline-flex; align-items: center; gap: 7px; margin-top: 14px;
++ padding: 6px 14px; border-radius: 999px; font-size: 0.78rem; text-decoration: none;
++ color: var(--text-secondary); background: color-mix(in srgb, var(--bg-secondary) 70%, transparent);
++ border: 1px solid color-mix(in srgb, var(--accent) 22%, transparent); backdrop-filter: blur(6px);
++ max-width: min(640px, 90vw); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
++}
++.hero-latest i { color: var(--accent); }
++.hero-latest b { color: var(--text-primary); font-weight: 600; overflow: hidden; text-overflow: ellipsis; }
++.hero-latest:hover { border-color: var(--accent); color: var(--text-primary); }
++
++/* ── Community pulse rail ── */
++.pulse-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; margin-bottom: 28px; }
++.pulse-card {
++ display: block; padding: 13px 15px; border-radius: 14px; text-decoration: none; color: var(--text-primary);
++ background: var(--bg-secondary); border: 1px solid var(--border);
++ transition: border-color 0.15s ease, transform 0.15s ease;
++}
++a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
++.pulse-kicker { font-size: 0.68rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-muted); margin-bottom: 7px; }
++.pulse-kicker i { color: var(--accent); margin-right: 4px; }
++.pulse-title { font-weight: 700; margin-bottom: 7px; }
++.pulse-sub { font-size: 0.78rem; color: var(--text-muted); margin-top: 5px; }
++.pulse-sub b { color: var(--accent); }
++.pulse-act { font-size: 0.85rem; padding: 4px 0; line-height: 1.45; }
++.pulse-act i { color: var(--accent); margin-right: 5px; }
++.pulse-rank { display: flex; align-items: center; gap: 7px; font-size: 0.85rem; padding: 3px 0; }
++.pulse-rank a { color: var(--text-primary); text-decoration: none; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
++.pulse-rank a:hover { color: var(--accent); }
++.pulse-rank b { font-variant-numeric: tabular-nums; }
++.pulse-medal { flex-shrink: 0; }
++
++/* ── AI Moments showcase row ── */
++.moments-row { display: flex; gap: 12px; overflow-x: auto; padding-bottom: 10px; margin-bottom: 22px; scrollbar-width: thin; }
++.moment-card {
++ position: relative; flex: 0 0 220px; height: 124px; border-radius: 12px; overflow: hidden;
++ border: 1px solid var(--border); transition: border-color 0.15s ease, transform 0.15s ease;
++}
++.moment-card:hover { border-color: var(--accent); transform: translateY(-2px); }
++.moment-card img { width: 100%; height: 100%; object-fit: cover; display: block; }
++.moment-overlay {
++ position: absolute; inset: auto 0 0 0; padding: 20px 10px 8px;
++ background: linear-gradient(transparent, rgba(0, 0, 0, 0.85));
++}
++.moment-title { font-size: 0.78rem; font-weight: 700; color: #fff; line-height: 1.25; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
++.moment-user { font-size: 0.68rem; color: rgba(255, 255, 255, 0.75); margin-top: 2px; }
++
++/* ── "While you were away" digest ── */
++.home-digest {
++ margin-bottom: 22px; padding: 13px 16px; border-radius: 14px;
++ background: linear-gradient(135deg, color-mix(in srgb, var(--accent) 10%, var(--bg-secondary)), var(--bg-secondary));
++ border: 1px solid color-mix(in srgb, var(--accent) 30%, var(--border));
++}
++.digest-head { font-size: 0.8rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-muted); margin-bottom: 9px; }
++.digest-head i { color: var(--accent); margin-right: 5px; }
++.digest-row { display: flex; flex-wrap: wrap; gap: 8px; }
++.digest-chip {
++ display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px 6px 7px; border-radius: 999px;
++ background: var(--bg-tertiary); border: 1px solid var(--border); text-decoration: none; color: var(--text-primary);
++ font-size: 0.84rem; transition: border-color 0.15s ease;
++}
++.digest-chip:hover { border-color: var(--accent); }
++.digest-chip--live { border-color: color-mix(in srgb, #e0245e 55%, var(--border)); }
++.digest-chip .stream-card-avatar { width: 24px; height: 24px; font-size: 0.7rem; }
++.digest-name { font-weight: 700; }
++.digest-extra { color: var(--text-muted); font-size: 0.76rem; }
++.digest-extra .live-dot { font-size: 0.5rem; }
++
++/* Recently Online cards: nearest-goal progress */
++.streamer-group-goal { display: flex; align-items: center; gap: 8px; margin: 6px 5px 2px; font-size: 0.76rem; }
++.streamer-group-goal .goal-bar { flex: 1; }
++.sgg-title { color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 42%; }
++.sgg-title i { color: var(--accent); margin-right: 3px; }
++.sgg-pct { font-weight: 700; color: var(--accent); font-variant-numeric: tabular-nums; }
.hero-stat {
display: flex; align-items: center; gap: 9px; padding: 9px 15px; border-radius: 14px;
background: color-mix(in srgb, var(--bg-secondary) 78%, transparent);
ddiff --git a/public/index.html b/public/index.html
iindex 3daff2df..eecae864 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -62,7 +62,7 @@
</script>
<!-- Stylesheets -->
-- <link rel="stylesheet" href="/css/style.css?v=137">
++ <link rel="stylesheet" href="/css/style.css?v=138">
<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">
@@@ -243,6 +243,8 @@
<i class="fa-solid fa-wand-magic-sparkles"></i> Vibe Coders
</a>
</div>
++ <!-- Newest shipped commit — built in the open, receipts included. -->
++ <a class="hero-latest" id="hero-latest" href="/updates" onclick="return handleLinkClick(event, '/updates')" style="display:none"></a>
<div class="hero-philosophy-links">
<a href="#home-cta-banner" class="hero-philosophy-btn" onclick="event.preventDefault();openHomeAbout();">
<i class="fa-solid fa-book-open"></i> What Is This? (Philosophy)
@@@ -257,6 +259,9 @@
</div>
<div class="container">
++ <!-- "While you were away" — returning users' followed-channel digest -->
++ <div class="home-digest" id="home-digest" style="display:none"></div>
++
<!-- Live Streams -->
<div class="section-header">
<h2><i class="fa-solid fa-circle live-dot"></i> Live Now</h2>
@@@ -270,6 +275,23 @@
</div>
</div>
++ <!-- Community pulse: goals near completion, latest activity, weekly leaders -->
++ <div id="home-pulse-section" style="display:none">
++ <div class="section-header">
++ <h2><i class="fa-solid fa-bolt"></i> Happening Now</h2>
++ </div>
++ <div class="pulse-grid" id="pulse-grid"></div>
++ </div>
++
++ <!-- AI Moments showcase -->
++ <div id="home-moments-section" style="display:none">
++ <div class="section-header">
++ <h2><i class="fa-solid fa-brain"></i> AI Moments</h2>
++ <span class="muted" style="font-size:0.8rem">wild seconds the AI clipped from recent streams</span>
++ </div>
++ <div class="moments-row" id="home-moments-row"></div>
++ </div>
++
<!-- Streaming CTA -->
<div class="home-cta-banner" id="home-cta-banner">
<div class="cta-ambient" aria-hidden="true"></div>
@@@ -4999,7 +5021,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=129" defer></script>
++<script src="/js/app.js?v=130" 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 8874e3ed..f4b28e37 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -1226,6 +1226,19 @@ function renderHeroStats(stats) {
if (stats.liveNow > 0) now.push({ cls: 'hero-stat--live', icon: 'fa-circle', num: stats.liveNow, label: 'Live', title: 'Streams live right now' });
if (stats.viewersNow > 0) now.push({ cls: 'hero-stat--live', icon: 'fa-eye', num: stats.viewersNow, label: 'Watching', title: 'Viewers watching right now' });
now.push({ icon: 'fa-fire', num: stats.weeklyActive, label: 'Active', title: 'Active this week — chatters incl. anons & relays' + (stats.weeklyVisitors ? `, plus ${stats.weeklyVisitors} new visitors` : ''), sub: stats.weeklyVisitors ? `+${_fmtCount(stats.weeklyVisitors)} new` : '' });
++ // 24h viewer sparkline (5-minute samples) — trends read better than a snapshot.
++ const trend = Array.isArray(stats.viewerTrend) ? stats.viewerTrend : [];
++ if (trend.length >= 2 && trend.some(t => (t.viewers || 0) > 0)) {
++ const max = Math.max(...trend.map(t => t.viewers || 0), 1);
++ const W = 110, H = 26;
++ const pts = trend.map((t, i) => `${(i / (trend.length - 1) * W).toFixed(1)},${(H - 2 - ((t.viewers || 0) / max) * (H - 4)).toFixed(1)}`).join(' ');
++ now.push({
++ html: `<div class="hero-stat hero-stat--spark" title="Viewers over the last 24h (peak ${max})">
++ <svg viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" preserveAspectRatio="none" aria-hidden="true">
++ <polyline points="${pts}" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
++ </svg><span class="hero-stat-label">24h viewers</span></div>`,
++ });
++ }
groups.push({ kicker: 'Right now', rows: now });
// ── Community ────────────────────────────────────────────────
@@@ -1265,6 +1278,7 @@ function renderHeroStats(stats) {
const recTitle = (rec, u = '') => rec ? ` — +${_fmtCount(rec.d)}${u} today · +${_fmtCount(rec.w)}${u} this week · +${_fmtCount(rec.m)}${u} this month` : '';
const recSub = (rec, u = '') => rec ? `+${_fmtCount(rec.w)}${u} wk` : '';
const 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>`;
@@@ -1643,6 +1657,8 @@ async function loadHome() {
} catch (e) { console.error('Failed to load live streams', e); }
loadHomeRecentOnline();
++ void loadHomePulse(); // happening-now rail + weekly leaders + AI moments + latest update
++ void loadHomeDigest(); // "while you were away" for returning logged-in users
// Load recent VODs
loadHomeRecentVods();
@@@ -1658,6 +1674,100 @@ async function loadHome() {
startHomeRefresh(); // live grid + sections auto-update in real time
}
++/* ── Community pulse: happening-now rail, weekly leaders, AI moments, latest ship ── */
++async function loadHomePulse() {
++ let p;
++ try { p = await api('/home/pulse'); } catch { return; }
++ if (!p) return;
++
++ // Hero one-liner: the newest shipped commit.
++ const latest = document.getElementById('hero-latest');
++ if (latest && p.latestUpdate && p.latestUpdate.subject) {
++ latest.innerHTML = `<i class="fa-solid fa-rocket"></i> shipped ${esc(timeAgo(p.latestUpdate.date))}: <b>${esc(p.latestUpdate.subject)}</b>`;
++ latest.style.display = '';
++ }
++
++ const grid = document.getElementById('pulse-grid');
++ const section = document.getElementById('home-pulse-section');
++ if (!grid || !section) return;
++ const cards = [];
++
++ // Goal cards — nearest to completion, with progress bars.
++ for (const g of (p.goals || [])) {
++ const pct = Math.min(100, Math.round((g.current_amount / g.target_amount) * 100));
++ const href = `/@${g.username}`;
++ cards.push(`
++ <a class="pulse-card pulse-goal" href="${href}" onclick="return handleLinkClick(event, '${href}')">
++ <div class="pulse-kicker"><i class="fa-solid fa-bullseye"></i> Goal · ${esc(g.display_name || g.username)}</div>
++ <div class="pulse-title">${esc(g.title)}</div>
++ <div class="goal-bar"><div class="goal-fill" style="width:${pct}%"></div></div>
++ <div class="pulse-sub">${Number(g.current_amount).toLocaleString()} / ${Number(g.target_amount).toLocaleString()} Vibes · <b>${pct}%</b></div>
++ </a>`);
++ }
++
++ // Latest activity card (tip + follow together).
++ const act = [];
++ if (p.latestTip) act.push(`<div class="pulse-act"><i class="fa-solid fa-hand-holding-dollar"></i> <b>${esc(p.latestTip.from_display || p.latestTip.from_username || 'Someone')}</b> tipped <b>${Number(p.latestTip.amount).toLocaleString()}</b> Vibes to <b>${esc(p.latestTip.to_display || p.latestTip.to_username)}</b> <span class="muted">${esc(timeAgo(p.latestTip.created_at))}</span></div>`);
++ if (p.newestFollow) act.push(`<div class="pulse-act"><i class="fa-solid fa-heart"></i> <b>${esc(p.newestFollow.follower_display || p.newestFollow.follower_username)}</b> followed <b>${esc(p.newestFollow.streamer_display || p.newestFollow.streamer_username)}</b> <span class="muted">${esc(timeAgo(p.newestFollow.created_at))}</span></div>`);
++ if (act.length) cards.push(`<div class="pulse-card"><div class="pulse-kicker"><i class="fa-solid fa-wave-square"></i> Latest activity</div>${act.join('')}</div>`);
++
++ // Weekly leader teasers.
++ const board = (title, icon, rows, unit) => rows && rows.length ? `
++ <div class="pulse-card">
++ <div class="pulse-kicker"><i class="fa-solid ${icon}"></i> ${title}</div>
++ ${rows.map((r, i) => `<div class="pulse-rank"><span class="pulse-medal">${['🥇', '🥈', '🥉'][i] || (i + 1)}</span> <a href="/@${esc(r.username)}" onclick="return handleLinkClick(event, '/@${esc(r.username)}')">${esc(r.display_name || r.username)}</a> <b>${Number(r.total).toLocaleString()}</b> <span class="muted">${unit}</span></div>`).join('')}
++ </div>` : '';
++ const supporters = board('Top supporters this week', 'fa-trophy', p.topSupporters, 'Vibes');
++ const earners = board('Top point earners this week', 'fa-coins', p.topEarners, 'pts');
++ if (supporters) cards.push(supporters);
++ if (earners) cards.push(earners);
++
++ section.style.display = cards.length ? '' : 'none';
++ grid.innerHTML = cards.join('');
++
++ // AI Moments showcase row.
++ const momentsRow = document.getElementById('home-moments-row');
++ const momentsSection = document.getElementById('home-moments-section');
++ if (momentsRow && momentsSection) {
++ const ms = (p.moments || []).filter(m => m.thumbnail);
++ momentsSection.style.display = ms.length ? '' : 'none';
++ momentsRow.innerHTML = ms.map(m => `
++ <a class="moment-card" href="${esc(m.href)}" onclick="return handleLinkClick(event, '${esc(m.href)}')">
++ <img src="${esc(m.thumbnail)}" alt="" loading="lazy">
++ <div class="moment-overlay">
++ <div class="moment-title">${esc(m.title)}</div>
++ ${m.username ? `<div class="moment-user">@${esc(m.username)}</div>` : ''}
++ </div>
++ </a>`).join('');
++ }
++}
++
++/* ── "While you were away" digest (logged-in returning users) ── */
++async function loadHomeDigest() {
++ const box = document.getElementById('home-digest');
++ if (!box || !currentUser) return;
++ const KEY = 'openvibe_last_visit';
++ let since = null;
++ try { since = localStorage.getItem(KEY); } catch { /* */ }
++ try { localStorage.setItem(KEY, new Date().toISOString()); } catch { /* */ }
++ // First visit (nothing to compare against) → no banner, just start the clock.
++ if (!since) return;
++ let d;
++ try { d = await api(`/home/digest?since=${encodeURIComponent(since)}`); } catch { return; }
++ if (!d || (!d.liveNow?.length && !d.missed?.length)) return;
++ const chip = (u, extra, live) => `
++ <a class="digest-chip ${live ? 'digest-chip--live' : ''}" href="/@${esc(u.username)}" onclick="return handleLinkClick(event, '/@${esc(u.username)}')">
++ ${_avatarSpan(u.avatar_url, u.username, u.profile_color)}
++ <span class="digest-name">${esc(u.display_name || u.username)}</span>
++ <span class="digest-extra">${extra}</span>
++ </a>`;
++ const parts = [];
++ for (const u of (d.liveNow || [])) parts.push(chip(u, '<i class="fa-solid fa-circle live-dot"></i> LIVE now', true));
++ for (const u of (d.missed || [])) parts.push(chip(u, `streamed ${u.sessions > 1 ? u.sessions + '× ' : ''}${esc(timeAgo(u.last_at))}`, false));
++ box.innerHTML = `<div class="digest-head"><i class="fa-solid fa-clock-rotate-left"></i> While you were away</div><div class="digest-row">${parts.join('')}</div>`;
++ box.style.display = '';
++}
++
async function loadHomeRecentOnline(page) {
if (page !== undefined) _homeRecentOnlinePage = page;
const offset = (_homeRecentOnlinePage - 1) * HOME_RECENT_ONLINE_PAGE_SIZE;
@@@ -1705,6 +1815,14 @@ function renderRecentlyOnline(containerId, streamers) {
</a>
</div>
${_cardAiHTML(s.ai_overview_short, s.ai_overview)}
++ ${s.top_goal ? (() => {
++ const pct = Math.min(100, Math.round((s.top_goal.current / s.top_goal.target) * 100));
++ return `<div class="streamer-group-goal" title="${esc(s.top_goal.title)}: ${Number(s.top_goal.current).toLocaleString()} / ${Number(s.top_goal.target).toLocaleString()} Vibes">
++ <span class="sgg-title"><i class="fa-solid fa-bullseye"></i> ${esc(s.top_goal.title)}</span>
++ <div class="goal-bar"><div class="goal-fill" style="width:${pct}%"></div></div>
++ <span class="sgg-pct">${pct}%</span>
++ </div>`;
++ })() : ''}
<div class="streamer-group-streams">${streamsHtml}</div>
</div>
`;
ddiff --git a/public/popout-chat.html b/public/popout-chat.html
iindex 45c9d2ce..2489a7dc 100644
---- a/public/popout-chat.html
++++ b/public/popout-chat.html
@@@ -7,7 +7,7 @@
<!-- Keep these in step with index.html — the popout renders the same chat
components (cosmetics.css positions particle effects; without it they sit
inline next to the username as "broken badges" until their cleanup timer). -->
-- <link rel="stylesheet" href="/css/style.css?v=137">
++ <link rel="stylesheet" href="/css/style.css?v=138">
<link rel="stylesheet" href="/css/chat-shared.css?v=1">
<link rel="stylesheet" href="/css/cosmetics.css?v=2">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 0e2453c1..3b9ccbd5 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -3788,6 +3788,84 @@ let _homeStatsCache = null;
let _homeStatsCacheAt = 0;
const _HOME_STATS_TTL = 30 * 1000; // 30s memo so the windowed COUNTs don't hammer SQLite
++// ── Viewer trend sampling (home hero sparkline) ──────────────
++// One row every ~5 minutes: total native viewers + live stream count.
++function _ensureViewerSamples() {
++ try {
++ getDb().exec(`CREATE TABLE IF NOT EXISTS viewer_samples (
++ sampled_at DATETIME DEFAULT CURRENT_TIMESTAMP,
++ viewers INTEGER NOT NULL DEFAULT 0,
++ live_streams INTEGER NOT NULL DEFAULT 0
++ )`);
++ getDb().exec('CREATE INDEX IF NOT EXISTS idx_viewer_samples_at ON viewer_samples(sampled_at)');
++ } catch { /* */ }
++}
++function recordViewerSample() {
++ _ensureViewerSamples();
++ const r = get(`SELECT COALESCE(SUM(viewer_count),0) AS v, COUNT(*) AS n FROM streams WHERE is_live = 1`) || { v: 0, n: 0 };
++ run('INSERT INTO viewer_samples (viewers, live_streams) VALUES (?, ?)', [r.v || 0, r.n || 0]);
++ run(`DELETE FROM viewer_samples WHERE sampled_at < datetime('now', '-7 days')`);
++ return r;
++}
++function getViewerTrend(hours = 24, maxPoints = 48) {
++ _ensureViewerSamples();
++ const rows = all(`SELECT strftime('%s', sampled_at) AS t, viewers, live_streams FROM viewer_samples
++ WHERE sampled_at >= datetime('now', ?) ORDER BY sampled_at ASC`, [`-${Math.max(1, hours)} hours`]) || [];
++ if (rows.length <= maxPoints) return rows;
++ const step = rows.length / maxPoints;
++ const out = [];
++ for (let i = 0; i < maxPoints; i++) out.push(rows[Math.floor(i * step)]);
++ return out;
++}
++
++// ── Home "pulse": goals near completion, latest activity, weekly leaders ──────
++function getHomePulse() {
++ const safe = (fn, dflt) => { try { return fn() ?? dflt; } catch { return dflt; } };
++ return {
++ // Active goals closest to completion, site-wide.
++ goals: safe(() => all(`SELECT g.id, g.title, g.current_amount, g.target_amount, g.image_url,
++ u.username, u.display_name
++ FROM donation_goals g JOIN users u ON u.id = g.user_id
++ WHERE g.is_active = 1 AND g.target_amount > 0
++ ORDER BY (CAST(g.current_amount AS REAL) / g.target_amount) DESC, g.current_amount DESC LIMIT 3`), []),
++ latestTip: safe(() => get(`SELECT t.amount, t.created_at,
++ fu.username AS from_username, fu.display_name AS from_display,
++ tu.username AS to_username, tu.display_name AS to_display
++ FROM transactions t
++ LEFT JOIN users fu ON fu.id = t.from_user_id
++ JOIN users tu ON tu.id = t.to_user_id
++ WHERE t.type = 'donation' ORDER BY t.id DESC LIMIT 1`), null),
++ newestFollow: safe(() => get(`SELECT f.created_at,
++ fu.username AS follower_username, fu.display_name AS follower_display,
++ su.username AS streamer_username, su.display_name AS streamer_display
++ FROM follows f
++ JOIN users fu ON fu.id = f.follower_id
++ JOIN users su ON su.id = f.streamer_id
++ ORDER BY f.id DESC LIMIT 1`), null),
++ // This week's leaders.
++ topSupporters: safe(() => all(`SELECT u.username, u.display_name, u.avatar_url, SUM(t.amount) AS total
++ FROM transactions t JOIN users u ON u.id = t.from_user_id
++ WHERE t.type = 'donation' AND t.created_at >= datetime('now', '-7 days')
++ GROUP BY t.from_user_id ORDER BY total DESC LIMIT 3`), []),
++ topEarners: safe(() => all(`SELECT u.username, u.display_name, u.avatar_url, SUM(c.amount) AS total
++ FROM coin_transactions c JOIN users u ON u.id = c.user_id
++ WHERE c.amount > 0 AND c.created_at >= datetime('now', '-7 days')
++ GROUP BY c.user_id ORDER BY total DESC LIMIT 3`), []),
++ };
++}
++
++// Nearest active goal per streamer (for Recently Online cards) — one query.
++function getActiveGoalsForUsers(userIds) {
++ if (!userIds || !userIds.length) return {};
++ const ph = userIds.map(() => '?').join(',');
++ const rows = all(`SELECT user_id, title, current_amount, target_amount FROM donation_goals
++ WHERE is_active = 1 AND target_amount > 0 AND user_id IN (${ph})
++ ORDER BY (CAST(current_amount AS REAL) / target_amount) DESC`, userIds) || [];
++ const out = {};
++ for (const r of rows) if (!out[r.user_id]) out[r.user_id] = r;
++ return out;
++}
++
function getHomeStats() {
const now = Date.now();
if (_homeStatsCache && (now - _homeStatsCacheAt) < _HOME_STATS_TTL) return _homeStatsCache;
@@@ -7817,6 +7895,7 @@ module.exports = {
scheduleClipNotifyState, bumpClipNotifyNowState, markClipNotifiedState, getDueClipNotifies,
getDb, initDb, run, get, all, close,
getDonationGoalsForWidget, getAllDonationGoals, getActiveDonationGoals, getDonationGoalById,
++ recordViewerSample, getViewerTrend, getHomePulse, getActiveGoalsForUsers,
createDonationGoal, updateDonationGoal, deleteDonationGoal, addToDonationGoal,
setChannelAlertSound, getChannelAlertSoundsByUser,
// Users
ddiff --git a/server/home/routes.js b/server/home/routes.js
iindex b48a4cf0..7c6b76b2 100644
---- a/server/home/routes.js
++++ b/server/home/routes.js
@@@ -188,13 +188,87 @@ async function heroStats() {
router.get('/hero', async (req, res) => {
try {
res.set('Cache-Control', 'public, max-age=20');
-- res.json({ stats: await heroStats(), media: await heroMedia(), moments: heroMoments(), slogans: heroSlogans() });
++ const stats = await heroStats();
++ // 24h viewer trend for the hero sparkline (sampled every 5 min by the sampler).
++ try { stats.viewerTrend = db.getViewerTrend(24, 48); } catch { stats.viewerTrend = []; }
++ res.json({ stats, media: await heroMedia(), moments: heroMoments(), slogans: heroSlogans() });
} catch (err) {
console.error('[Home] hero error:', err.message);
res.status(500).json({ error: 'Failed to load hero' });
}
});
++// ── Community pulse: goals near completion, latest activity, weekly leaders,
++// AI moment showcase, and the newest shipped update. Cached ~30s server-side.
++let _pulseCache = { at: 0, data: null };
++router.get('/pulse', async (req, res) => {
++ try {
++ res.set('Cache-Control', 'public, max-age=30');
++ if (Date.now() - _pulseCache.at < 30_000 && _pulseCache.data) return res.json(_pulseCache.data);
++ const pulse = db.getHomePulse();
++ pulse.moments = heroMoments().slice(0, 10);
++ pulse.latestUpdate = _latestUpdate();
++ _pulseCache = { at: Date.now(), data: pulse };
++ res.json(pulse);
++ } catch (err) {
++ console.error('[Home] pulse error:', err.message);
++ res.status(500).json({ error: 'Failed to load pulse' });
++ }
++});
++
++// Newest git commit = the latest shipped update (built-in-the-open hero one-liner).
++let _updateCache = { at: 0, data: null };
++function _latestUpdate() {
++ if (Date.now() - _updateCache.at < 5 * 60_000) return _updateCache.data;
++ try {
++ const { execSync } = require('child_process');
++ const raw = execSync(`git --no-pager log --pretty=format:'%h||%s||%aI' -1`,
++ { cwd: require('path').join(__dirname, '../..'), encoding: 'utf8', timeout: 4000 });
++ const [short, subject, date] = String(raw).trim().split('||');
++ _updateCache = { at: Date.now(), data: subject ? { short, subject, date } : null };
++ } catch { _updateCache = { at: Date.now(), data: null }; }
++ return _updateCache.data;
++}
++
++// ── "While you were away" digest for returning users ─────────────────────────
++// ?since=<ISO> (the client remembers its own last visit). Followed channels that
++// are live right now + those that streamed since the last visit.
++const { requireAuth } = require('../auth/auth');
++router.get('/digest', requireAuth, (req, res) => {
++ try {
++ const since = req.query.since && !Number.isNaN(Date.parse(req.query.since))
++ ? new Date(req.query.since).toISOString()
++ : new Date(Date.now() - 3 * 24 * 3600 * 1000).toISOString();
++ const liveNow = db.all(`SELECT DISTINCT u.username, u.display_name, u.avatar_url
++ FROM follows f JOIN users u ON u.id = f.streamer_id
++ JOIN streams s ON s.user_id = f.streamer_id AND s.is_live = 1
++ WHERE f.follower_id = ?`, [req.user.id]) || [];
++ const missed = db.all(`SELECT u.username, u.display_name, u.avatar_url,
++ COUNT(s.id) AS sessions, MAX(s.started_at) AS last_at, MAX(s.title) AS last_title
++ FROM follows f JOIN users u ON u.id = f.streamer_id
++ JOIN streams s ON s.user_id = f.streamer_id AND s.started_at >= ? AND s.is_live = 0
++ WHERE f.follower_id = ?
++ GROUP BY f.streamer_id ORDER BY last_at DESC LIMIT 6`, [since, req.user.id]) || [];
++ // Don't list a channel as "missed" when it's live right now — it's in liveNow.
++ const liveSet = new Set(liveNow.map(x => x.username));
++ res.json({ since, liveNow, missed: missed.filter(m => !liveSet.has(m.username)) });
++ } catch (err) {
++ console.error('[Home] digest error:', err.message);
++ res.status(500).json({ error: 'Failed to load digest' });
++ }
++});
++
++// Sample the live viewer total every 5 minutes for the hero sparkline.
++let _samplerTimer = null;
++function startViewerSampler() {
++ if (_samplerTimer) return;
++ try { db.recordViewerSample(); } catch { /* */ }
++ _samplerTimer = setInterval(() => { try { db.recordViewerSample(); } catch { /* */ } }, 5 * 60 * 1000);
++ if (_samplerTimer.unref) _samplerTimer.unref();
++ console.log('[Home] viewer sampler started (5m)');
++}
++
module.exports = router;
++module.exports.startViewerSampler = startViewerSampler;
module.exports.FALLBACK_AUDIENCES = FALLBACK_AUDIENCES;
module.exports.FALLBACK_QUIPS = FALLBACK_QUIPS;
ddiff --git a/server/index.js b/server/index.js
iindex c7f2f09b..9ecd98bf 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -1018,6 +1018,8 @@ async function start() {
try { require('./integrations/powerchat-platform').startCurrencyEarnFlusher(); } catch (e) { console.warn('[PowerChat] earn flusher not started:', e.message); }
// Auto-renew lapsed channel subs (Vibes-balance renewal; Stripe renews natively).
try { require('./monetization/payments').startRenewalSweeper(); } catch (e) { console.warn('[Payments] renewal sweeper not started:', e.message); }
++ // 5-minute live-viewer samples → the home hero's 24h sparkline.
++ try { require('./home/routes').startViewerSampler(); } catch (e) { console.warn('[Home] viewer sampler not started:', e.message); }
// PowerChat: prune the webhook-dedupe log daily so it can't grow unbounded.
try {
const _pcClean = () => { try { db.cleanupPowerchatDeliveries(3); } catch { /* */ } };
ddiff --git a/server/streaming/routes.js b/server/streaming/routes.js
iindex d5048a5e..1cd75d6d 100644
---- a/server/streaming/routes.js
++++ b/server/streaming/routes.js
@@@ -1114,6 +1114,14 @@ router.get('/recently-online', async (req, res) => {
} catch (err) {
console.warn('[Streaming] recently-online thumb enrichment failed:', err.message);
}
++ // Each streamer's nearest active donation goal → progress bar on the card.
++ try {
++ const goals = db.getActiveGoalsForUsers(streamers.map(s => s.user_id).filter(Boolean));
++ for (const s of streamers) {
++ const g = goals[s.user_id];
++ if (g) s.top_goal = { title: g.title, current: g.current_amount, target: g.target_amount };
++ }
++ } catch { /* best-effort */ }
res.json({ streamers, total, limit, offset, hasMore: offset + streamers.length < total });
} catch (err) {
console.error('[Streaming]', err.message);