openvibers/openvibe.live Commits
Home stats: every number once (visitors and anons were one table), charts that load (Chatted and Messages answered 404 on a cold cache), new streamers per day, the two hours named, hours watched per week, weekly figures compared with the week before, labels that fit on phones (#406)
Changes
@@@ -1186,3 +1186,36 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }#page-home .hero .hero-quip { font-size: 14px; margin: 0 0 8px; }#page-home .hero .hero-slogan-timer { display: none; }}++++/* ── Stat board, 2026-10-27 review ──────────────────────────────────────────++ A seven-day figure's pill is only its comparison with the week before ("7d ▼3%"), so the trend needs no++ divider of its own. */++.hero-stat-delta.is-window .hero-stat-trend { margin-left: 3px; padding-left: 0; border-left: 0; }++.hero-stat-strip .hero-stat-delta.is-window .hero-stat-trend { margin-left: 0; }++/* The 24-hour viewers line has an area under it, and opens the Watching chart. */++.hero-stat--spark .hero-spark-area { fill: currentColor; opacity: 0.16; stroke: none; }++.hero-stat--spark .hero-stat-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }++/* Phones: three chips to a row leave a label ~60 px beside its icon, and "WATCHING" broke as "WATCHIN G". The icon++ now sits beside the number and the label takes the chip's whole width under them. */++@media (max-width: 640px) {++ .hero-stat-full .hero-stat:not(.hero-stat--spark) { grid-template-columns: auto minmax(0, 1fr); align-items: center; }++ .hero-stat-full .hero-stat:not(.hero-stat--spark) .hero-stat-meta { display: contents; }++ .hero-stat-full .hero-stat:not(.hero-stat--spark) > i { grid-row: 1; grid-column: 1; }++ .hero-stat-full .hero-stat-num { grid-row: 1; grid-column: 2; }++ .hero-stat-full .hero-stat-label { grid-column: 1 / -1; overflow-wrap: normal; word-break: normal; }++ .hero-stat-full .hero-stat-sub, .hero-stat-full .hero-stat-delta { grid-column: 1 / -1; }++}++/* Under the board: every site's numbers on openvibe.network. */++.hero-stat-all-sites {++ display: flex; align-items: center; justify-content: center; gap: 9px; white-space: nowrap;++ margin: 10px auto 2px; padding: 9px 16px; width: fit-content; max-width: 100%;++ border-radius: 999px; text-decoration: none; font-size: 0.8rem; font-weight: 700; text-align: center;++ color: var(--text-primary); background: color-mix(in srgb, var(--accent) 10%, transparent);++ border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);++ transition: border-color 0.2s, background 0.2s, transform 0.18s;++}++.hero-stat-all-sites > i:first-child { color: var(--accent); }++.hero-stat-all-sites > i:last-child { font-size: 0.7rem; color: var(--text-muted); transition: transform 0.18s; }++.hero-stat-all-sites:hover, .hero-stat-all-sites:focus-visible { border-color: color-mix(in srgb, var(--accent) 60%, transparent); background: color-mix(in srgb, var(--accent) 16%, transparent); outline: none; }++.hero-stat-all-sites:hover > i:last-child { transform: translateX(3px); }++@media (prefers-reduced-motion: reduce) { .hero-stat-all-sites, .hero-stat-all-sites > i { transition: none; } }@@@ -100,6 +100,10 @@.sn-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 10px; font-size: 0.72rem; color: var(--text-muted); }.sn-csv { background: none; border: 0; color: var(--text-secondary); cursor: pointer; font: inherit; font-size: 0.74rem; display: inline-flex; align-items: center; gap: 6px; padding: 4px 6px; border-radius: 8px; }.sn-csv:hover { color: var(--text-primary); background: color-mix(in srgb, var(--text-primary) 6%, transparent); }++/* Every site's numbers (openvibe.network/stats), beside the CSV download. */++.sn-all { margin-left: auto; color: var(--text-secondary); text-decoration: none; font-size: 0.74rem; display: inline-flex; align-items: center; gap: 6px; padding: 4px 6px; border-radius: 8px; white-space: nowrap; }++.sn-all:hover, .sn-all:focus-visible { color: var(--text-primary); background: color-mix(in srgb, var(--text-primary) 6%, transparent); outline: none; }++.sn-all + .sn-csv { margin-left: 0; }@keyframes snFade { from { opacity: 0; } }@keyframes snFadeOut { to { opacity: 0; } }@@@ -236,6 +236,30 @@ function _baselineDeltaHTML(now, avg, peak, window = '24h') {+ `<b>${pct > 0 ? '+' : ''}${pct}%</b><em>vs ${window}</em></span>`;}++/**++ * The change against the seven days before, as a small arrow and a percentage ('' when there is no previous week).++ * The triangle is drawn in CSS rather than set as an icon glyph, so it stays crisp and tiny at this size.++ */++function _heroTrendBits(w, pw) {++ if (!Number.isFinite(pw)) return '';++ if (pw === 0) return w > 0 ? '<span class="hero-stat-trend is-new">new</span>' : '';++ const pct = Math.round(((w - pw) / pw) * 100);++ if (pct === 0) return '<span class="hero-stat-trend is-flat"><i></i>flat</span>';++ return `<span class="hero-stat-trend ${pct > 0 ? 'is-up' : 'is-down'}"><i></i>${Math.abs(pct)}%</span>`;++}++/**++ * The pill of a number that already is a seven-day window (people who chatted this week): only how it compares with++ * the week before. "+66 7d" under a chip reading 66 said the same number twice.++ */++function _heroWindowDeltaHTML(w, pw) {++ w = Number(w); pw = Number(pw);++ if (!Number.isFinite(w) || !Number.isFinite(pw)) return '';++ const trend = _heroTrendBits(w, pw);++ if (!trend) return '';++ const cls = pw === 0 ? 'is-up' : w > pw ? 'is-up' : w < pw ? 'is-down' : 'is-flat';++ return `<span class="hero-stat-delta is-window ${cls}" title="${esc(`${_fmtCount(w)} in the last 7 days, against ${_fmtCount(pw)} the seven days before`)}"><em>7d</em>${trend}</span>`;++}++function _heroStatFmt(n) {n = Math.max(0, Math.round(n || 0));if (n >= 1000000) return (n / 1000000).toFixed(n >= 10000000 ? 0 : 1).replace(/\.0$/, '') + 'M';@@@ -276,23 +300,31 @@ function _renderHeroStats(stats) {const groups = [];// ── Right now ────────────────────────────────────────────────++ // Every number appears once on the board. Visitors and "anons" were the same table (each new address is given an++ // anon name) shown twice, so they are one chip; the weekly figures say only how they compare with the week before.const now = [{ key: 'liveNow', metric: 'liveNow', deltaHTML: _baselineDeltaHTML(stats.liveNow, CC.liveAvg24h, CC.livePeak24h), 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' },{ key: 'viewersNow', metric: 'viewersNow', deltaHTML: _baselineDeltaHTML(stats.viewersNow, CC.viewersAvg24h, CC.viewersPeak24h), cls: stats.viewersNow > 0 ? 'hero-stat--live' : '', icon: 'fa-eye', num: stats.viewersNow, label: 'Watching', title: 'Viewers watching right now' },-- { key: 'weeklyActive', icon: 'fa-fire', num: stats.weeklyActive, label: 'Active', 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' },-- { key: 'weeklyVisitors', icon: 'fa-user-plus', num: stats.weeklyVisitors, label: 'Visitors', 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' },++ { key: 'weeklyActive', icon: 'fa-fire', num: stats.weeklyActive, label: 'Chatted', title: 'People who chatted in the last 7 days', desc: 'Distinct chatters in the last 7 days: signed-in people, anonymous chatters and chatters relayed from Twitch, Kick and YouTube, each counted once.', metric: 'active', deltaHTML: _heroWindowDeltaHTML(stats.weeklyActive, stats.prevWeeklyActive) },++ ...(stats.streamedWeek != null ? [{ key: 'streamedWeek', icon: 'fa-tower-broadcast', num: stats.streamedWeek, label: 'Went live', title: 'People who went live in the last 7 days', deltaHTML: _heroWindowDeltaHTML(stats.streamedWeek, stats.prevStreamedWeek) }] : []),];-- // 24h viewer sparkline (5-minute samples) — trends read better than a snapshot.++ // Viewers over the last 24 hours (5-minute samples): trends read better than a snapshot. It opens the++ // Watching chart on its last 24 hours.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 = 220, H = 30;-- const pts = trend.map((t, i) => `${(i / (trend.length - 1) * W).toFixed(1)},${(H - 2 - ((t.viewers || 0) / max) * (H - 4)).toFixed(1)}`).join(' ');++ const W = 220, H = 34;++ const xy = trend.map((t, i) => [(i / (trend.length - 1) * W).toFixed(1), (H - 2 - ((t.viewers || 0) / max) * (H - 6)).toFixed(1)]);++ const line = xy.map(p => p.join(',')).join(' ');++ const area = `M0,${H} L${xy.map(p => p.join(',')).join(' L')} L${W},${H} Z`;++ const tip = { label: 'Viewers, last 24 hours', title: `People watching over the last 24 hours, sampled every 5 minutes · peak ${max}`, metric: 'viewersNow' };now.push({-- html: `<div class="hero-stat hero-stat--spark" title="Viewers over the last 24h (peak ${max})">++ spark: true,++ html: `<div class="hero-stat hero-stat--spark hero-stat--clickable" data-metric="viewersNow" data-days="1" data-tip="${esc(JSON.stringify(tip))}" role="button" tabindex="0" aria-label="Viewers over the last 24 hours, peak ${max} — show over time"><svg viewBox="0 0 ${W} ${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 · peak ${max}</span></div>`,++ <path d="${area}" class="hero-spark-area"/>++ <polyline points="${line}" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke"/>++ </svg><span class="hero-stat-label">Viewers · 24h · peak ${max}</span></div>`,});}groups.push({ kicker: 'Right now', icon: 'fa-bolt', rows: now });@@@ -300,32 +332,31 @@ function _renderHeroStats(stats) {// ── Community ────────────────────────────────────────────────groups.push({kicker: 'Community', icon: 'fa-people-group', rows: [-- { key: 'streamers', recent: R.streamers, icon: 'fa-satellite-dish', num: stats.streamers, label: 'Streamers', metric: 'streamers', title: 'People who have gone live' },-- { key: 'users', icon: 'fa-users', num: stats.users, label: 'Users', metric: 'users', title: 'Registered users', recent: R.users },-- { key: 'anons', icon: 'fa-user-secret', num: stats.anons, label: 'Anons', metric: 'anons', title: 'Anonymous chatters ever seen', recent: R.anons },++ { key: 'anons', icon: 'fa-user-plus', num: stats.anons, label: 'Visitors', metric: 'visitors', title: 'Addresses that have visited Live', desc: 'Each new address that visits Live is given an anonymous name (anon1234), so this counts visits from new addresses: no account, no tracking. A phone that changes networks counts again, so it is more than the number of people.', recent: R.anons },++ { key: 'users', icon: 'fa-users', num: stats.users, label: 'Accounts', metric: 'users', title: 'People with an account on OpenVibe.Live', recent: R.users },++ { key: 'streamers', recent: R.streamers, icon: 'fa-satellite-dish', num: stats.streamers, label: 'Streamers', metric: 'streamers', title: 'People who have gone live here', desc: `People who have gone live here. "+" is new streamers: people whose first stream was in that window.${stats.streamedWeek != null ? ` ${_fmtCount(stats.streamedWeek)} went live in the last 7 days.` : ''}` },{ icon: 'fa-heart', num: stats.follows, label: 'Follows', metric: 'follows', title: 'Channel follows', recent: R.follows },{ key: 'chatMessages', icon: 'fa-comments', num: stats.chatMessages, label: 'Messages', metric: 'messages', title: 'Chat messages sent', recent: R.messages },-- { key: 'hoursWatched', recent: R.hours, unit: 'h', icon: 'fa-couch', num: stats.hoursWatched, label: 'Hrs Watched', metric: 'hoursWatched', title: 'Hours the community has spent watching streams', unit: 'h' },++ { key: 'hoursWatched', recent: R.hoursWatched, unit: 'h', icon: 'fa-couch', num: stats.hoursWatched, label: 'Hours watched', metric: 'hoursWatched', title: 'Hours people have spent watching streams here' },],});// ── Economy ──────────────────────────────────────────────────groups.push({kicker: 'Economy', icon: 'fa-coins', rows: [-- { icon: 'fa-hand-holding-dollar', num: stats.vibesTipped, label: 'Vibes Tipped', metric: 'vibes', title: 'Vibes donated between people (100 Vibes = $1)', recent: R.vibes },-- { recent: R.supporters, 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-hand-holding-dollar', num: stats.vibesTipped, label: 'Vibes tipped', metric: 'vibes', title: 'Vibes tipped to streamers (100 Vibes = $1), on Live, PowerChat and OpenVibe.Tips', recent: R.vibes },++ { recent: R.supporters, icon: 'fa-hand-holding-heart', num: stats.supporters, label: 'Supporters', metric: 'supporters', title: 'People who have tipped a streamer' },++ { icon: 'fa-cart-shopping', num: stats.vibesBought, label: 'Vibes bought', metric: 'vibesBought', title: 'Vibes bought 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 },-- // OpenCoins are network-wide; channel points below are per channel. Shown only when-- // the wallet service answered — four zeroed chips would read as "nobody has any".++ // OpenCoins are network-wide; channel points below are per channel. Shown only when the wallet service++ // answered: zeroed chips would read as "nobody has any". (Coins held was coins earned less 50 spent: gone.)...(stats.coinsEarned != null ? [-- { recent: R.coinsEarned, icon: 'fa-circle-dollar-to-slot', num: stats.coinsEarned, label: 'Coins Earned', title: 'OpenCoins earned across the whole network — the site-wide currency you get for watching, chatting and using the tools' },-- { recent: R.coinsSpent, icon: 'fa-basket-shopping', num: stats.coinsSpent, label: 'Coins Spent', title: 'OpenCoins spent on emotes, themes, cosmetics and sounds' },-- { icon: 'fa-vault', num: stats.coinsCirculating, label: 'Coins Held', title: 'OpenCoins sitting in wallets right now, across every OpenVibe site' },++ { recent: R.coinsEarned, icon: 'fa-circle-dollar-to-slot', num: stats.coinsEarned, label: 'Coins earned', title: 'OpenCoins earned across the whole network — the site-wide currency you get for watching, chatting and using the tools', desc: `OpenCoins earned across the whole network: the site-wide currency you get for watching, chatting and using the tools.${stats.coinsCirculating != null ? ` ${_fmtCount(stats.coinsCirculating)} are in wallets right now.` : ''}` },++ { recent: R.coinsSpent, icon: 'fa-basket-shopping', num: stats.coinsSpent, label: 'Coins spent', title: 'OpenCoins spent on emotes, themes, cosmetics and sounds' },{ recent: R.coinHolders, icon: 'fa-wallet', num: stats.coinHolders, label: 'Wallets', title: 'People holding OpenCoins' },] : []),-- { icon: 'fa-coins', num: stats.pointsEarned, label: 'Points Earned', metric: 'points', title: 'Channel points earned by viewers (watching, chatting, following) — per channel, unlike OpenCoins', 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-coins', num: stats.pointsEarned, label: 'Points earned', metric: 'points', title: 'Channel points earned by viewers (watching, chatting, following) — per channel, unlike OpenCoins', 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` : '' },{ recent: R.goals, 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` : '' },],});@@@ -333,11 +364,11 @@ function _renderHeroStats(stats) {// ── Archive ──────────────────────────────────────────────────groups.push({kicker: 'Archive', icon: 'fa-box-archive', rows: [-- { icon: 'fa-tower-broadcast', num: stats.liveSessions, label: 'Sessions', metric: 'sessions', title: 'Total stream sessions', recent: R.sessions },++ { key: 'liveSessions', icon: 'fa-tower-broadcast', num: stats.liveSessions, label: 'Streams', metric: 'sessions', title: 'Streams started here, all time', 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-clock', num: stats.streamHours, label: 'Hours archived', metric: 'hours', title: 'Hours of video kept in the archive', 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 },// The emotes series is gone (OpenVibe.Chat owns the table; Live stopped reading it in T3// N+2), so the chip and its chart are skipped — `stats.emotes` is null, the same "no series"// signal the OpenCoins chips use. No other markup changes.@@@ -363,14 +394,7 @@ function _renderHeroStats(stats) {* `rec` is { w, pw }: this window and the one before it. Either may be absent, and the pill* degrades to whichever half it has rather than disappearing.*/-- const trendBits = (w, pw) => {-- if (!Number.isFinite(pw)) return '';-- if (pw === 0) return w > 0 ? '<span class="hero-stat-trend is-new">new</span>' : '';-- const pct = Math.round(((w - pw) / pw) * 100);-- if (pct === 0) return '<span class="hero-stat-trend is-flat"><i></i>flat</span>';-- const up = pct > 0;-- return `<span class="hero-stat-trend ${up ? 'is-up' : 'is-down'}"><i></i>${Math.abs(pct)}%</span>`;-- };++ const trendBits = _heroTrendBits;/*** The two instantaneous readings — streams live and people watching — against what's normal.*@@@ -392,7 +416,7 @@ function _renderHeroStats(stats) {+ '</span>';};const chip = (r) => {-- if (r.html) return r.html; // pre-rendered chips (sparkline)++ if (r.html) return r.html; // pre-rendered chips (the sparkline)const sub = r.sub || recSub(r.recent, r.unit || '');const tip = {label: r.label, title: r.title || '', desc: r.desc || '',@@@ -415,13 +439,13 @@ function _renderHeroStats(stats) {const HEADLINE = [{ key: 'liveNow', metric: 'liveNow', icon: stats.liveNow > 0 ? 'fa-circle' : 'fa-circle-dot', cls: stats.liveNow > 0 ? 'hero-stat--live' : '', num: stats.liveNow, label: 'Live', title: 'Streams live right now', deltaHTML: _baselineDeltaHTML(stats.liveNow, CC.liveAvg24h, CC.livePeak24h) },{ key: 'viewersNow', metric: 'viewersNow', icon: 'fa-eye', cls: stats.viewersNow > 0 ? 'hero-stat--live' : '', num: stats.viewersNow, label: 'Watching', title: 'Viewers watching right now', deltaHTML: _baselineDeltaHTML(stats.viewersNow, CC.viewersAvg24h, CC.viewersPeak24h) },-- { key: 'weeklyActive', metric: 'active', icon: 'fa-fire', num: stats.weeklyActive, label: 'Active', title: 'People who chatted in the last 7 days', recent: { w: stats.weeklyActive, pw: stats.prevWeeklyActive } },-- { key: 'users', metric: 'users', icon: 'fa-user-group', num: stats.users, label: 'Users', title: 'Accounts on OpenVibe.Live', recent: R.users },-- { key: 'weeklyVisitors', metric: 'visitors', icon: 'fa-user-plus', num: stats.weeklyVisitors, label: 'Visitors', title: 'First-time visitors in the last 7 days', recent: { w: stats.weeklyVisitors, pw: stats.prevWeeklyVisitors } },-- { key: 'anons', metric: 'anons', icon: 'fa-user-secret', num: stats.anons, label: 'Anons', title: 'Anonymous chatters who have been given a name', recent: R.anons },++ { key: 'weeklyActive', metric: 'active', icon: 'fa-fire', num: stats.weeklyActive, label: 'Chatted', title: 'People who chatted in the last 7 days', deltaHTML: _heroWindowDeltaHTML(stats.weeklyActive, stats.prevWeeklyActive) },++ { key: 'anons', metric: 'visitors', icon: 'fa-user-plus', num: stats.anons, label: 'Visitors', title: 'Addresses that have visited Live (each is given an anon name)', recent: R.anons },{ key: 'chatMessages', metric: 'messages', icon: 'fa-comments', num: stats.chatMessages, label: 'Messages', title: 'Chat messages sent, all time', recent: R.messages },-- { key: 'streamers', metric: 'streamers', icon: 'fa-satellite-dish', num: stats.streamers, label: 'Streamers', title: 'People who have gone live here', recent: R.streamers },-- { key: 'hoursWatched', metric: 'hoursWatched', icon: 'fa-couch', num: stats.hoursWatched, label: 'Hours', title: 'Hours the community has spent watching', recent: R.hours },++ { key: 'streamers', metric: 'streamers', icon: 'fa-satellite-dish', num: stats.streamers, label: 'Streamers', title: 'People who have gone live here ("+" is new streamers)', recent: R.streamers },++ { key: 'liveSessions', metric: 'sessions', icon: 'fa-tower-broadcast', num: stats.liveSessions, label: 'Streams', title: 'Streams started here, all time', recent: R.sessions },++ { key: 'users', metric: 'users', icon: 'fa-user-group', num: stats.users, label: 'Accounts', title: 'People with an account on OpenVibe.Live', recent: R.users },++ { key: 'hoursWatched', metric: 'hoursWatched', icon: 'fa-couch', num: stats.hoursWatched, label: 'Watch hrs', title: 'Hours people have spent watching streams here', recent: R.hoursWatched, unit: 'h' },];// Always starts collapsed. Persisting "expanded" meant every reload rendered and counted up// two dozen extra chips before the page had finished loading, for a view the reader asked for@@@ -443,7 +467,7 @@ function _renderHeroStats(stats) {const btn = wrap.querySelector('#hero-stat-more');const full = wrap.querySelector('#hero-stat-full');const strip = wrap.querySelector('.hero-stat-strip');-- const total = groups.reduce((n, g) => n + g.rows.length, 0);++ const total = groups.reduce((n, g) => n + g.rows.filter(r => !r.spark).length, 0);const label = () => { btn.querySelector('.hero-stat-more-text').textContent = full.hidden ? `Show all ${total} stats` : 'Show fewer stats'; };const sync = () => { strip.hidden = !full.hidden; btn.classList.toggle('is-open', !full.hidden); label(); };sync();@@@ -537,12 +561,16 @@ function _startHeroStatsLive() {users: d.recent?.users, anons: d.recent?.anons,chatMessages: d.recent?.messages,streamers: d.recent?.streamers,-- // hoursWatched is deliberately absent: its 7-day figure comes from OpenVibe.Media-- // (that is where VODs live now), and this endpoint only knows the local number,-- // which is zero. Overwriting it here would wipe a correct value with a wrong one.-- weeklyActive: { w: d.weeklyActive, pw: d.prevWeeklyActive },-- weeklyVisitors: { w: d.weeklyVisitors, pw: d.prevWeeklyVisitors },++ liveSessions: d.recent?.sessions,++ hoursWatched: d.recent?.hoursWatched,};++ // A seven-day figure's pill is only its comparison with the week before: rebuilt whole when it changes.++ const WINDOWED = { weeklyActive: [d.weeklyActive, d.prevWeeklyActive] };++ for (const [key, [w, pw]] of Object.entries(WINDOWED)) {++ const html = _heroWindowDeltaHTML(w, pw);++ if (!html) continue;++ wrap.querySelectorAll(`[data-stat="${key}"] .hero-stat-delta`).forEach(el => { if (el.outerHTML !== html) el.outerHTML = html; });++ }// The two instantaneous readings are re-rendered whole: their pill is a percentage// against a moving baseline, not a running total, so patching one number isn't enough.const cc = d.concurrency || {};@@@ -30,6 +30,9 @@vibesBought: 'fa-cart-shopping', subs: 'fa-star', points: 'fa-coins', pointsSpent: 'fa-gift', redemptions: 'fa-gift',emotes: 'fa-face-grin-squint',};++ // Headcounts per day (people who chatted, people who tipped): the days do not add up, so these have no running++ // total and no "added" figure; their chart is the daily count, and the chip's own number is the week's.++ const DISTINCT = new Set(['active', 'supporters']);const RANGES = [{ days: 1, label: '24h', readingOnly: true },{ days: 7, label: '7d' }, { days: 30, label: '30d' }, { days: 90, label: '90d' }, { days: 365, label: '1y' },@@@ -91,6 +94,9 @@state.lastFocus = document.activeElement;const isReading = (m) => m.metric === 'liveNow' || m.metric === 'viewersNow';state.days = isReading(state.metrics[state.index]) ? 7 : 30;++ // The 24-hour viewers line opens its own last 24 hours.++ const asked = Number(chip?.dataset?.days);++ if (RANGES.some((r) => r.days === asked && (!r.readingOnly || isReading(state.metrics[state.index])))) state.days = asked;state.view = isReading(state.metrics[state.index]) ? 'avg' : 'daily';state.table = false;build();@@@ -184,7 +190,8 @@if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 1.5) step(dx < 0 ? 1 : -1);}, { passive: true });const plot = ov.querySelector('.sn-plot');-- state.ro = new ResizeObserver(() => { if (state.data && !state.table) drawChart(); });++ // Only a chart that has points: an error answer has none, and redrawing it threw on every resize.++ state.ro = new ResizeObserver(() => { if (state.data?.points && !state.table) drawChart(); });state.ro.observe(plot);renderHead();ov.querySelector('.sn-dialog').focus({ preventScroll: true });@@@ -210,7 +217,8 @@}));const views = m.kind === 'reading'? [['avg', 'Average'], ['peak', 'Peak']]-- : [['daily', 'Per day'], ['total', 'Running total']];++ : DISTINCT.has(m.metric) ? [['daily', 'Per day']] : [['daily', 'Per day'], ['total', 'Running total']];++ if (DISTINCT.has(m.metric)) state.view = 'daily';const viewSeg = ov.querySelector('.sn-views');viewSeg.replaceChildren(...views.map(([k, label]) => {const b = el('button', k === state.view ? 'is-on' : '', label);@@@ -303,6 +311,13 @@}const best = d.points.reduce((a, p) => (p.value > a.value ? p : a), { value: -1 });const avg = d.total / d.points.length;++ if (DISTINCT.has(m.metric)) {++ return [++ ['Last 7 days', exact(m.now, unit)],++ [`Average day · ${rangeName()}`, exact(avg, unit)],++ ['Busiest day', best.value > 0 ? exact(best.value, unit) : '—', best.value > 0 ? el('span', 'sn-tile-sub', dayLabel(best.day)) : null],++ ];++ }const tiles = [[`Added · ${rangeName()}`, exact(d.total, unit), deltaPill(d.total, d.prev_total)],['Per day', exact(avg, unit)],@@@ -484,7 +499,8 @@const wrap = ov.querySelector('.sn-table');const t = el('table');const head = el('tr');-- const cols = d.kind === 'reading' ? [d.bucket === 'hour' ? 'Hour' : 'Day', 'Average', 'Peak'] : ['Day', 'Added', 'Running total'];++ const distinct = DISTINCT.has(current().metric);++ const cols = d.kind === 'reading' ? [d.bucket === 'hour' ? 'Hour' : 'Day', 'Average', 'Peak'] : distinct ? ['Day', 'People'] : ['Day', 'Added', 'Running total'];cols.forEach((c) => head.append(el('th', null, c)));const thead = el('thead'); thead.append(head);const tbody = el('tbody');@@@ -494,6 +510,8 @@tr.append(el('td', null, bucketLabel(p)));if (d.kind === 'reading') {tr.append(el('td', 'num', exact(p.value, unit)), el('td', 'num', exact(p.peak, unit)));++ } else if (distinct) {++ tr.append(el('td', 'num', exact(p.value, unit)));} else {run += p.value;tr.append(el('td', 'num', exact(p.value, unit)), el('td', 'num', exact(run, unit)));@@@ -508,10 +526,12 @@function downloadCsv() {const d = state.data; if (!d || !d.points) return;const m = current();-- const lines = [d.kind === 'reading' ? 'bucket,average,peak' : 'day,value,running_total'];++ const distinct = DISTINCT.has(m.metric);++ const lines = [d.kind === 'reading' ? 'bucket,average,peak' : distinct ? 'day,people' : 'day,value,running_total'];let run = Number(d.before) || 0;for (const p of d.points) {if (d.kind === 'reading') lines.push(`${p.t},${p.value ?? ''},${p.peak ?? ''}`);++ else if (distinct) lines.push(`${p.day},${p.value}`);else { run += p.value; lines.push(`${p.day},${p.value},${Math.round(run * 100) / 100}`); }}const blob = new Blob([lines.join('\n') + '\n'], { type: 'text/csv' });@@@ -1525,6 +1525,8 @@ async function vibesStatsSince() {// One supporter in channel_tips, with or without a Live account (PowerChat and OpenVibe.Tips supporters have a name only).const TIP_SUPPORTER = "COALESCE(from_user_id::text, 'n:' || lower(COALESCE(username, '')))";const TIP_SUPPORTER_T = "COALESCE(t.from_user_id::text, 'n:' || lower(COALESCE(t.username, '')))";++// Each person's first stream: when they became a streamer.++const FIRST_STREAMS = 'SELECT user_id, MIN(created_at) AS created_at FROM streams WHERE user_id IS NOT NULL GROUP BY user_id';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(*)' },@@@ -1534,13 +1536,16 @@ const HOME_SERIES = {// getHomeStatSeries from Chat's `site-daily` stats read; there is no Live-table fallback any more// (since 2026-10-05 Live keeps no copy of Chat's tables), so outside chat mode they answer null.sessions: { table: 'streams', ts: 'created_at', agg: 'COUNT(*)' },-- streamers: { table: 'streams', ts: 'created_at', agg: 'COUNT(DISTINCT user_id)' },++ // New streamers: each person counted once, on the day of their first stream, so the running total is the chip's++ // all-time headcount. (Summing each day's distinct streamers counted a regular once per day they streamed.)++ streamers: { table: `(${FIRST_STREAMS}) first_streams`, ts: 'created_at', agg: 'COUNT(*)' },// vods, clips, hours and pastes are OpenVibe.Media's series (home/routes.js MEDIA_SERIES).hoursWatched:{ table: 'watch_time', ts: 'created_at', agg: 'COALESCE(SUM(minutes_watched)::float8, 0) / 60.0' },aiMoments: { table: 'stream_memories', ts: 'created_at', agg: 'COUNT(*)' },// Tips from channel_tips (every rail: on-site Vibes, PowerChat, OpenVibe.Tips), not the money ledger.vibes: { table: 'channel_tips', ts: 'created_at', agg: 'COALESCE(SUM(amount)::float8, 0)', vibesReset: true },-- supporters: { table: 'channel_tips', ts: 'created_at', agg: `COUNT(DISTINCT ${TIP_SUPPORTER})`, vibesReset: true },++ // A headcount per day (`distinct`): the days do not add up, so the chart offers no running total.++ supporters: { table: 'channel_tips', ts: 'created_at', agg: `COUNT(DISTINCT ${TIP_SUPPORTER})`, vibesReset: true, distinct: true },// vibesBought and subs are OpenVibe.Billing's series (home/routes.js, monetization/billing-stats.js).points: { table: 'coin_transactions', ts: 'created_at', agg: 'COALESCE(SUM(amount)::float8, 0)', where: 'amount > 0' },pointsSpent: { table: 'coin_transactions', ts: 'created_at', agg: 'COALESCE(-SUM(amount)::float8, 0)', where: 'amount < 0' },@@@ -1579,10 +1584,12 @@ async function homeSeriesLocal(metric, days = 30) {const w = [...(def.where ? [def.where] : []), ...(def.vibesReset ? [`${def.ts} >= '${await vibesStatsSince()}'`] : []), extra];try { return Number((await get(`SELECT ${def.agg} AS value FROM ${def.table} WHERE ${w.join(' AND ')}`, params))?.value) || 0; } catch { return 0; }};-- const before = await scalar(`${def.ts} < datetime('now', ?)`, [`-${days - 1} days`]);++ // Rows with no timestamp (most anon_ip_mappings rows predate the column) are part of everything before the window,++ // so the running total ends at the chip's all-time number.++ const before = await scalar(`(${def.ts} < datetime('now', ?) OR ${def.ts} IS NULL)`, [`-${days - 1} days`]);const prevTotal = await scalar(`${def.ts} >= datetime('now', ?) AND ${def.ts} < datetime('now', ?)`, [`-${2 * days - 1} days`, `-${days - 1} days`]);return { metric, kind: 'count', days, points, total, peak: Math.max(0, ...points.map(p => p.value)),-- before: Number(before.toFixed(2)), prev_total: Number(prevTotal.toFixed(2)) };++ before: Number(before.toFixed(2)), prev_total: Number(prevTotal.toFixed(2)), ...(def.distinct ? { distinct: true } : {}) };}/**@@@ -1593,9 +1600,11 @@ async function homeSeriesLocal(metric, days = 30) {*/async function getHomeStatSeries(metric, days = 30) {if (metric === 'messages' || metric === 'active') {++ // Awaited, not peeked: a peek is null on every cold key (each range, each new UTC day), and the chart answered++ // "Unknown metric" for it. `active` is a headcount per day, so its days do not add up (`distinct`).try {-- const s = await require('../chat/chat-reads').homeSeriesPeek(metric, days);-- if (s) return s;++ const s = await require('../chat/chat-reads').homeSeries(metric, days);++ if (s) return metric === 'active' ? { ...s, distinct: true } : s;} catch { /* fall through to Live's own tables */ }}return await homeSeriesLocal(metric, days);@@@ -1675,10 +1684,13 @@ async function _computeHomeStats() {const prev = await c(`SELECT COALESCE(SUM(${col})::float8, 0) AS count FROM ${table} WHERE ${tsCol} >= datetime('now', '-14 days') AND ${tsCol} < datetime('now', '-7 days')${extra ? ' AND ' + extra : ''}`);return { d: await q('-1 day'), w: await q('-7 days'), m: await q('-30 days'), pw: prev };};-- // Distinct people who went live in a window — "streamers" is a headcount, not a stream count.-- const streamersWin = async () => {-- const q = async (a, b) => await c(`SELECT COUNT(DISTINCT user_id) AS count FROM streams WHERE user_id IS NOT NULL AND created_at >= datetime('now', ?)${b ? " AND created_at < datetime('now', ?)" : ''}`, b ? [a, b] : [a]);-- return { d: await q('-1 day'), w: await q('-7 days'), m: await q('-30 days'), pw: await q('-14 days', '-7 days') };++ // New streamers in a window: people whose first stream falls in it, so "+N" on the chip adds to its headcount++ // (and matches the chart). Distinct people who streamed in the window is `streamedWeek`.++ const streamersWin = async () => await winCount(`(${FIRST_STREAMS}) first_streams`, 'created_at');++ // Hours watched in a window (watch-time heartbeats are minutes).++ const hoursWin = async () => {++ const m = await winSum('watch_time', 'minutes_watched', 'created_at');++ return { d: Math.round(m.d / 60), w: Math.round(m.w / 60), m: Math.round(m.m / 60), pw: Math.round(m.pw / 60) };};return {// ── Right-now + community-economy metrics ────────────────@@@ -1708,6 +1720,9 @@ async function _computeHomeStats() {clips: null,liveSessions: await c(`SELECT COUNT(*) AS count FROM streams`),streamers: await c(`SELECT COUNT(DISTINCT user_id) AS count FROM streams WHERE user_id IS NOT NULL`),++ // Distinct people who went live in the last seven days, and the seven before.++ streamedWeek: await c(`SELECT COUNT(DISTINCT user_id) AS count FROM streams WHERE user_id IS NOT NULL AND created_at >= datetime('now', '-7 days')`),++ prevStreamedWeek: await c(`SELECT COUNT(DISTINCT user_id) AS count FROM streams WHERE user_id IS NOT NULL AND created_at >= datetime('now', '-14 days') AND created_at < datetime('now', '-7 days')`),// OpenVibe.Chat owns chat_messages (roadmap T3): the total comes from Chat's read API.// A Chat outage answers the cache, else null — never a 500 for the home page.chatMessages: await (async () => { try { const s = await require('../chat/chat-reads').siteStatsPeek(); return s && s.messages != null ? s.messages : null; } catch { return null; } })(),@@@ -1758,6 +1773,7 @@ async function _computeHomeStats() {})(),hours: null, // OpenVibe.Mediastreamers: await streamersWin(),++ hoursWatched: await hoursWin(),// Live's `emotes` copy was unread since N+2 and is now dropped, so the// deltas are zero. The key stays for the N-1 client, which reads stats.recent.emotes.emotes: { d: 0, w: 0, m: 0, pw: 0 },@@@ -197,6 +197,8 @@ router.get('/stats/series/:metric', async (req, res) => {} else {series = await db.getReadingSeries(metric, days) || await db.getHomeStatSeries(metric, days);}++ // A metric this route knows that has no answer right now (Chat's two, while Chat is away) is unavailable, not unknown.++ if (!series && db.HOME_SERIES_KEYS.includes(metric)) return res.status(503).json({ error: 'This stat is unavailable right now. Try again in a minute.' });if (!series) return res.status(404).json({ error: 'Unknown metric', metrics: [...db.HOME_SERIES_KEYS, ...MEDIA_SERIES, 'pastes', 'vibesBought', 'subs'] });if (_seriesCache.size > 200) _seriesCache.clear();_seriesCache.set(key, { at: Date.now(), data: series });@@@ -297,7 +299,7 @@ router.get('/stats-live', async (req, res) => {const data = {liveNow: s.liveNow, viewersNow: s.viewersNow, weeklyActive: s.weeklyActive,users: s.users, weeklyVisitors: s.weeklyVisitors, anons: s.anons, chatMessages: s.chatMessages,-- streamers: s.streamers, hoursWatched: s.hoursWatched,++ streamers: s.streamers, hoursWatched: s.hoursWatched, liveSessions: s.liveSessions,// The 7-day deltas the hero chips show under their numbers. Same shape as the full// board's `recent`, trimmed to the four that have one.prevWeeklyVisitors: s.prevWeeklyVisitors, prevWeeklyActive: s.prevWeeklyActive,@@@ -308,9 +310,9 @@ router.get('/stats-live', async (req, res) => {users: s.recent?.users, anons: s.recent?.anons,messages: s.recent?.messages,streamers: s.recent?.streamers,-- // No `hours` here on purpose — the real figure comes from OpenVibe.Media and is-- // merged into the hero payload; the local number is zero and would be wrong.--++ sessions: s.recent?.sessions,++ hoursWatched: s.recent?.hoursWatched,++ // No `hours` (hours archived) here: that one is OpenVibe.Media's, merged into the hero payload only.},};_liveStats = { at: Date.now(), data };@@@ -855,11 +855,16 @@ const waitFor = async (pred, what, ms = 3000) => {const activeSeries = chatReads.homeSeriesPeek('active', 7);assert.strictEqual(activeSeries.points[4].value, 2, 'two days ago: two distinct chatters');assert.strictEqual(activeSeries.points[5].value, 2, 'yesterday: two distinct chatters');-- // getHomeStatSeries routes the two chat metrics through the chat series.-- const realSeriesPeek = chatReads.homeSeriesPeek;-- chatReads.homeSeriesPeek = () => ({ metric: 'messages', sentinel: true });-- try { assert.strictEqual((await d2.getHomeStatSeries('messages', 7)).sentinel, true, 'getHomeStatSeries reads the chat series in chat mode'); }-- finally { chatReads.homeSeriesPeek = realSeriesPeek; }++ // getHomeStatSeries routes the two chat metrics through the chat series, awaited: a peek was null on every++ // cold key (each range, each new UTC day), and the chart answered "Unknown metric". `active` is a daily++ // headcount, marked `distinct` (its days do not add up).++ const realSeries = chatReads.homeSeries;++ chatReads.homeSeries = async (metric) => ({ metric, sentinel: true });++ try {++ assert.strictEqual((await d2.getHomeStatSeries('messages', 7)).sentinel, true, 'getHomeStatSeries reads the chat series in chat mode');++ assert.strictEqual((await d2.getHomeStatSeries('messages', 7)).distinct, undefined, 'messages add up');++ assert.strictEqual((await d2.getHomeStatSeries('active', 7)).distinct, true, 'active is a daily headcount');++ } finally { chatReads.homeSeries = realSeries; }// A cold cache during a Chat outage answers null (Live keeps no chat series to fall back to).chatReads._reset();readState.down = true;nnew file mode 100644@@@ -0,0 +1,54 @@++/**++ * The home page's stat board (public/js/app-home.js) and its charts (public/js/stats-nerds.js), after the 2026-10-27++ * review: every number appears once, a seven-day figure's pill is only its comparison with the week before, and a++ * daily headcount's chart has no running total.++ *++ * node test/home-stat-board.test.js++ */++'use strict';++const assert = require('assert');++const fs = require('fs');++const path = require('path');++++const src = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app-home.js'), 'utf8');++const fnSource = (name) => {++ const start = src.indexOf(`function ${name}(`);++ assert.ok(start >= 0, name);++ let i = src.indexOf('{', start), depth = 0;++ for (; i < src.length; i++) { if (src[i] === '{') depth++; else if (src[i] === '}' && --depth === 0) break; }++ return src.slice(start, i + 1);++};++// eslint-disable-next-line no-new-func++const windowDelta = new Function('_fmtCount', 'esc', `${fnSource('_heroTrendBits')}\n${fnSource('_heroWindowDeltaHTML')}; return _heroWindowDeltaHTML;`)(String, (s) => String(s));++++// "+66 7d" under a chip reading 66 said the number twice: the pill is only the comparison.++const pill = windowDelta(66, 68);++assert.match(pill, /is-window is-down/);++assert.match(pill, /<em>7d<\/em><span class="hero-stat-trend is-down"><i><\/i>3%<\/span>/);++assert.ok(!/\+66/.test(pill), 'the week\'s own number is not repeated');++assert.match(windowDelta(5, 0), /is-new/, 'nothing the week before: new');++assert.strictEqual(windowDelta(5, undefined), '', 'no previous week: no pill');++assert.match(windowDelta(4, 4), /is-flat/);++++// The board: each chart metric once, and visitors are one chip (the same table was "Visitors" and "Anons").++// (The 24-hour viewers line opens the Watching chart too: it is a picture of that metric, not another number.)++const boardAll = src.slice(src.indexOf('// ── Right now ──'), src.indexOf('// Rolling-window deltas'));++const board = boardAll.slice(0, boardAll.indexOf('const trend = Array.isArray')) + boardAll.slice(boardAll.indexOf("groups.push({ kicker: 'Right now'"));++const metrics = [...board.matchAll(/metric: '([a-zA-Z]+)'/g)].map((m) => m[1]);++const twice = metrics.filter((m, i) => metrics.indexOf(m) !== i);++assert.deepStrictEqual(twice, [], `each metric once on the board (twice: ${twice.join(', ')})`);++assert.ok(metrics.includes('visitors') && !metrics.includes('anons'), 'one visitors chip');++assert.ok(!/label: 'Coins Held'|label: 'Coins held'/.test(board), 'coins held was coins earned less 50: gone');++assert.ok(board.includes("label: 'Hours watched'") && board.includes("label: 'Hours archived'"), 'the two hours say which');++const strip = src.slice(src.indexOf('const HEADLINE = ['), src.indexOf('];', src.indexOf('const HEADLINE = [')));++const stripMetrics = [...strip.matchAll(/metric: '([a-zA-Z]+)'/g)].map((m) => m[1]);++assert.strictEqual(new Set(stripMetrics).size, stripMetrics.length, 'the strip shows each metric once');++assert.strictEqual(stripMetrics.length, 9, 'nine chips: three rows of three on a phone');++++// The chart dialog: daily headcounts have no running total, and an error answer is never redrawn.++const nerds = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'stats-nerds.js'), 'utf8');++assert.ok(/const DISTINCT = new Set\(\['active', 'supporters'\]\)/.test(nerds));++assert.ok(nerds.includes("DISTINCT.has(m.metric) ? [['daily', 'Per day']]"), 'a headcount offers only per day');++assert.ok(nerds.includes('if (state.data?.points && !state.table) drawChart();'), 'a resize never redraws an error answer');++assert.ok(nerds.includes('const asked = Number(chip?.dataset?.days);'), 'the 24-hour line opens its own 24 hours');++console.log('home stat board: all checks passed');@@@ -85,5 +85,32 @@ await db.setSetting('stats_vibes_reset_at', 'garbage');assert.strictEqual(await 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');++// ── 2026-10-27 review: new streamers, visitors' running total, hours watched per window ──++// Bob streams three days in a row and Alice once, 2 days ago: the chart counts each person once, on their first++// stream, so its running total is the chip's headcount (summing each day's streamers counted Bob three times).++for (const back of [5, 4, 3]) await db.run("INSERT INTO streams (user_id, title, created_at) VALUES (?, 'b', datetime('now', ?))", [b, `-${back} days`]);++await db.run("INSERT INTO streams (user_id, title, created_at) VALUES (?, 'a', datetime('now', '-2 days'))", [a]);++const streamers7 = await db.getHomeStatSeries('streamers', 7);++assert.strictEqual(streamers7.total, 2, 'two new streamers in the week, Bob once');++assert.strictEqual(streamers7.points[6 - 5].value, 1, 'Bob on his first day only');++assert.strictEqual(streamers7.points[6 - 4].value, 0);++assert.strictEqual(streamers7.before + streamers7.total, 2, 'the running total ends at the headcount');++// Most anon rows predate created_at: they are part of everything before the window, so the running total ends at++// the chip's number (it said 1,812 against 29,144 on production).++await db.run("INSERT INTO anon_ip_mappings (ip, anon_num, created_at) VALUES ('10.0.0.1', 901, NULL), ('10.0.0.2', 902, NULL), ('10.0.0.3', 903, datetime('now', '-1 hours'))");++const visitors7 = await db.getHomeStatSeries('visitors', 7);++assert.strictEqual(visitors7.total, 1);++assert.strictEqual(visitors7.before, 2, 'rows with no date count before the window');++// Hours watched over each window, and distinct streamers this week.++await db.run("INSERT INTO watch_time (user_id, stream_id, minutes_watched, created_at) SELECT ?, id, 120, datetime('now', '-2 hours') FROM streams WHERE user_id = ? LIMIT 1", [a, b]);++await db.run("INSERT INTO watch_time (user_id, stream_id, minutes_watched, created_at) SELECT ?, id, 180, datetime('now', '-10 days') FROM streams WHERE user_id = ? LIMIT 1", [b, a]);++stats = await db._computeHomeStats();++assert.deepStrictEqual(stats.recent.hoursWatched, { d: 2, w: 2, m: 5, pw: 3 }, 'hours watched per window, and the week before');++assert.deepStrictEqual(stats.recent.streamers, { d: 0, w: 2, m: 2, pw: 0 }, '"+" on Streamers is new streamers');++assert.strictEqual(stats.streamedWeek, 2, 'two people went live this week');++assert.strictEqual((await db.getHomeStatSeries('supporters', 7)).distinct, true, 'supporters per day do not add up');++assert.strictEqual((await db.getHomeStatSeries('users', 7)).distinct, undefined);++console.log('✅ new streamers, visitors before the window, hours watched per window');++console.log('\n✅ All home-stats tests passed');})().catch((e) => { console.error(e); process.exit(1); });
@@ -1186,3 +1186,36 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
@@ -1186,3 +1186,36 @@ body.egg-rainbow { animation: eggRainbow 2.6s linear; }
#page-home .hero .hero-quip { font-size: 14px; margin: 0 0 8px; }
#page-home .hero .hero-quip { font-size: 14px; margin: 0 0 8px; }
#page-home .hero .hero-slogan-timer { display: none; }
#page-home .hero .hero-slogan-timer { display: none; }
}
}
+
+/* ── Stat board, 2026-10-27 review ──────────────────────────────────────────
+ A seven-day figure's pill is only its comparison with the week before ("7d ▼3%"), so the trend needs no
+ divider of its own. */
+.hero-stat-delta.is-window .hero-stat-trend { margin-left: 3px; padding-left: 0; border-left: 0; }
+.hero-stat-strip .hero-stat-delta.is-window .hero-stat-trend { margin-left: 0; }
+/* The 24-hour viewers line has an area under it, and opens the Watching chart. */
+.hero-stat--spark .hero-spark-area { fill: currentColor; opacity: 0.16; stroke: none; }
+.hero-stat--spark .hero-stat-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+/* Phones: three chips to a row leave a label ~60 px beside its icon, and "WATCHING" broke as "WATCHIN G". The icon
+ now sits beside the number and the label takes the chip's whole width under them. */
+@media (max-width: 640px) {
+ .hero-stat-full .hero-stat:not(.hero-stat--spark) { grid-template-columns: auto minmax(0, 1fr); align-items: center; }
+ .hero-stat-full .hero-stat:not(.hero-stat--spark) .hero-stat-meta { display: contents; }
+ .hero-stat-full .hero-stat:not(.hero-stat--spark) > i { grid-row: 1; grid-column: 1; }
+ .hero-stat-full .hero-stat-num { grid-row: 1; grid-column: 2; }
+ .hero-stat-full .hero-stat-label { grid-column: 1 / -1; overflow-wrap: normal; word-break: normal; }
+ .hero-stat-full .hero-stat-sub, .hero-stat-full .hero-stat-delta { grid-column: 1 / -1; }
+}
+/* Under the board: every site's numbers on openvibe.network. */
+.hero-stat-all-sites {
+ display: flex; align-items: center; justify-content: center; gap: 9px; white-space: nowrap;
+ margin: 10px auto 2px; padding: 9px 16px; width: fit-content; max-width: 100%;
+ border-radius: 999px; text-decoration: none; font-size: 0.8rem; font-weight: 700; text-align: center;
+ color: var(--text-primary); background: color-mix(in srgb, var(--accent) 10%, transparent);
+ border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
+ transition: border-color 0.2s, background 0.2s, transform 0.18s;
+}
+.hero-stat-all-sites > i:first-child { color: var(--accent); }
+.hero-stat-all-sites > i:last-child { font-size: 0.7rem; color: var(--text-muted); transition: transform 0.18s; }
+.hero-stat-all-sites:hover, .hero-stat-all-sites:focus-visible { border-color: color-mix(in srgb, var(--accent) 60%, transparent); background: color-mix(in srgb, var(--accent) 16%, transparent); outline: none; }
+.hero-stat-all-sites:hover > i:last-child { transform: translateX(3px); }
+@media (prefers-reduced-motion: reduce) { .hero-stat-all-sites, .hero-stat-all-sites > i { transition: none; } }
@@ -100,6 +100,10 @@
@@ -100,6 +100,10 @@
.sn-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 10px; font-size: 0.72rem; color: var(--text-muted); }
.sn-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 10px; font-size: 0.72rem; color: var(--text-muted); }
.sn-csv { background: none; border: 0; color: var(--text-secondary); cursor: pointer; font: inherit; font-size: 0.74rem; display: inline-flex; align-items: center; gap: 6px; padding: 4px 6px; border-radius: 8px; }
.sn-csv { background: none; border: 0; color: var(--text-secondary); cursor: pointer; font: inherit; font-size: 0.74rem; display: inline-flex; align-items: center; gap: 6px; padding: 4px 6px; border-radius: 8px; }
.sn-csv:hover { color: var(--text-primary); background: color-mix(in srgb, var(--text-primary) 6%, transparent); }
.sn-csv:hover { color: var(--text-primary); background: color-mix(in srgb, var(--text-primary) 6%, transparent); }
+/* Every site's numbers (openvibe.network/stats), beside the CSV download. */
+.sn-all { margin-left: auto; color: var(--text-secondary); text-decoration: none; font-size: 0.74rem; display: inline-flex; align-items: center; gap: 6px; padding: 4px 6px; border-radius: 8px; white-space: nowrap; }
+.sn-all:hover, .sn-all:focus-visible { color: var(--text-primary); background: color-mix(in srgb, var(--text-primary) 6%, transparent); outline: none; }
+.sn-all + .sn-csv { margin-left: 0; }
@keyframes snFade { from { opacity: 0; } }
@keyframes snFade { from { opacity: 0; } }
@keyframes snFadeOut { to { opacity: 0; } }
@keyframes snFadeOut { to { opacity: 0; } }
@@ -236,6 +236,30 @@ function _baselineDeltaHTML(now, avg, peak, window = '24h') {
@@ -236,6 +236,30 @@ function _baselineDeltaHTML(now, avg, peak, window = '24h') {
+ `<b>${pct > 0 ? '+' : ''}${pct}%</b><em>vs ${window}</em></span>`;
+ `<b>${pct > 0 ? '+' : ''}${pct}%</b><em>vs ${window}</em></span>`;
}
}
+/**
+ * The change against the seven days before, as a small arrow and a percentage ('' when there is no previous week).
+ * The triangle is drawn in CSS rather than set as an icon glyph, so it stays crisp and tiny at this size.
+ */
+function _heroTrendBits(w, pw) {
+ if (!Number.isFinite(pw)) return '';
+ if (pw === 0) return w > 0 ? '<span class="hero-stat-trend is-new">new</span>' : '';
+ const pct = Math.round(((w - pw) / pw) * 100);
+ if (pct === 0) return '<span class="hero-stat-trend is-flat"><i></i>flat</span>';
+ return `<span class="hero-stat-trend ${pct > 0 ? 'is-up' : 'is-down'}"><i></i>${Math.abs(pct)}%</span>`;
+}
+/**
+ * The pill of a number that already is a seven-day window (people who chatted this week): only how it compares with
+ * the week before. "+66 7d" under a chip reading 66 said the same number twice.
+ */
+function _heroWindowDeltaHTML(w, pw) {
+ w = Number(w); pw = Number(pw);
+ if (!Number.isFinite(w) || !Number.isFinite(pw)) return '';
+ const trend = _heroTrendBits(w, pw);
+ if (!trend) return '';
+ const cls = pw === 0 ? 'is-up' : w > pw ? 'is-up' : w < pw ? 'is-down' : 'is-flat';
+ return `<span class="hero-stat-delta is-window ${cls}" title="${esc(`${_fmtCount(w)} in the last 7 days, against ${_fmtCount(pw)} the seven days before`)}"><em>7d</em>${trend}</span>`;
+}
+
function _heroStatFmt(n) {
function _heroStatFmt(n) {
n = Math.max(0, Math.round(n || 0));
n = Math.max(0, Math.round(n || 0));
if (n >= 1000000) return (n / 1000000).toFixed(n >= 10000000 ? 0 : 1).replace(/\.0$/, '') + 'M';
if (n >= 1000000) return (n / 1000000).toFixed(n >= 10000000 ? 0 : 1).replace(/\.0$/, '') + 'M';
@@ -276,23 +300,31 @@ function _renderHeroStats(stats) {
@@ -276,23 +300,31 @@ function _renderHeroStats(stats) {
const groups = [];
const groups = [];
// ── Right now ────────────────────────────────────────────────
// ── Right now ────────────────────────────────────────────────
+ // Every number appears once on the board. Visitors and "anons" were the same table (each new address is given an
+ // anon name) shown twice, so they are one chip; the weekly figures say only how they compare with the week before.
const now = [
const now = [
{ key: 'liveNow', metric: 'liveNow', deltaHTML: _baselineDeltaHTML(stats.liveNow, CC.liveAvg24h, CC.livePeak24h), 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' },
{ key: 'liveNow', metric: 'liveNow', deltaHTML: _baselineDeltaHTML(stats.liveNow, CC.liveAvg24h, CC.livePeak24h), 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' },
{ key: 'viewersNow', metric: 'viewersNow', deltaHTML: _baselineDeltaHTML(stats.viewersNow, CC.viewersAvg24h, CC.viewersPeak24h), cls: stats.viewersNow > 0 ? 'hero-stat--live' : '', icon: 'fa-eye', num: stats.viewersNow, label: 'Watching', title: 'Viewers watching right now' },
{ key: 'viewersNow', metric: 'viewersNow', deltaHTML: _baselineDeltaHTML(stats.viewersNow, CC.viewersAvg24h, CC.viewersPeak24h), cls: stats.viewersNow > 0 ? 'hero-stat--live' : '', icon: 'fa-eye', num: stats.viewersNow, label: 'Watching', title: 'Viewers watching right now' },
- { key: 'weeklyActive', icon: 'fa-fire', num: stats.weeklyActive, label: 'Active', 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' },
- { key: 'weeklyVisitors', icon: 'fa-user-plus', num: stats.weeklyVisitors, label: 'Visitors', 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' },
+ { key: 'weeklyActive', icon: 'fa-fire', num: stats.weeklyActive, label: 'Chatted', title: 'People who chatted in the last 7 days', desc: 'Distinct chatters in the last 7 days: signed-in people, anonymous chatters and chatters relayed from Twitch, Kick and YouTube, each counted once.', metric: 'active', deltaHTML: _heroWindowDeltaHTML(stats.weeklyActive, stats.prevWeeklyActive) },
+ ...(stats.streamedWeek != null ? [{ key: 'streamedWeek', icon: 'fa-tower-broadcast', num: stats.streamedWeek, label: 'Went live', title: 'People who went live in the last 7 days', deltaHTML: _heroWindowDeltaHTML(stats.streamedWeek, stats.prevStreamedWeek) }] : []),
];
];
- // 24h viewer sparkline (5-minute samples) — trends read better than a snapshot.
+ // Viewers over the last 24 hours (5-minute samples): trends read better than a snapshot. It opens the
+ // Watching chart on its last 24 hours.
const trend = Array.isArray(stats.viewerTrend) ? stats.viewerTrend : [];
const trend = Array.isArray(stats.viewerTrend) ? stats.viewerTrend : [];
if (trend.length >= 2 && trend.some(t => (t.viewers || 0) > 0)) {
if (trend.length >= 2 && trend.some(t => (t.viewers || 0) > 0)) {
const max = Math.max(...trend.map(t => t.viewers || 0), 1);
const max = Math.max(...trend.map(t => t.viewers || 0), 1);
- const W = 220, H = 30;
- const pts = trend.map((t, i) => `${(i / (trend.length - 1) * W).toFixed(1)},${(H - 2 - ((t.viewers || 0) / max) * (H - 4)).toFixed(1)}`).join(' ');
+ const W = 220, H = 34;
+ const xy = trend.map((t, i) => [(i / (trend.length - 1) * W).toFixed(1), (H - 2 - ((t.viewers || 0) / max) * (H - 6)).toFixed(1)]);
+ const line = xy.map(p => p.join(',')).join(' ');
+ const area = `M0,${H} L${xy.map(p => p.join(',')).join(' L')} L${W},${H} Z`;
+ const tip = { label: 'Viewers, last 24 hours', title: `People watching over the last 24 hours, sampled every 5 minutes · peak ${max}`, metric: 'viewersNow' };
now.push({
now.push({
- html: `<div class="hero-stat hero-stat--spark" title="Viewers over the last 24h (peak ${max})">
+ spark: true,
+ html: `<div class="hero-stat hero-stat--spark hero-stat--clickable" data-metric="viewersNow" data-days="1" data-tip="${esc(JSON.stringify(tip))}" role="button" tabindex="0" aria-label="Viewers over the last 24 hours, peak ${max} — show over time">
<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" aria-hidden="true">
<svg viewBox="0 0 ${W} ${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 · peak ${max}</span></div>`,
+ <path d="${area}" class="hero-spark-area"/>
+ <polyline points="${line}" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" vector-effect="non-scaling-stroke"/>
+ </svg><span class="hero-stat-label">Viewers · 24h · peak ${max}</span></div>`,
});
});
}
}
groups.push({ kicker: 'Right now', icon: 'fa-bolt', rows: now });
groups.push({ kicker: 'Right now', icon: 'fa-bolt', rows: now });
@@ -300,32 +332,31 @@ function _renderHeroStats(stats) {
@@ -300,32 +332,31 @@ function _renderHeroStats(stats) {
// ── Community ────────────────────────────────────────────────
// ── Community ────────────────────────────────────────────────
groups.push({
groups.push({
kicker: 'Community', icon: 'fa-people-group', rows: [
kicker: 'Community', icon: 'fa-people-group', rows: [
- { key: 'streamers', recent: R.streamers, icon: 'fa-satellite-dish', num: stats.streamers, label: 'Streamers', metric: 'streamers', title: 'People who have gone live' },
- { key: 'users', icon: 'fa-users', num: stats.users, label: 'Users', metric: 'users', title: 'Registered users', recent: R.users },
- { key: 'anons', icon: 'fa-user-secret', num: stats.anons, label: 'Anons', metric: 'anons', title: 'Anonymous chatters ever seen', recent: R.anons },
+ { key: 'anons', icon: 'fa-user-plus', num: stats.anons, label: 'Visitors', metric: 'visitors', title: 'Addresses that have visited Live', desc: 'Each new address that visits Live is given an anonymous name (anon1234), so this counts visits from new addresses: no account, no tracking. A phone that changes networks counts again, so it is more than the number of people.', recent: R.anons },
+ { key: 'users', icon: 'fa-users', num: stats.users, label: 'Accounts', metric: 'users', title: 'People with an account on OpenVibe.Live', recent: R.users },
+ { key: 'streamers', recent: R.streamers, icon: 'fa-satellite-dish', num: stats.streamers, label: 'Streamers', metric: 'streamers', title: 'People who have gone live here', desc: `People who have gone live here. "+" is new streamers: people whose first stream was in that window.${stats.streamedWeek != null ? ` ${_fmtCount(stats.streamedWeek)} went live in the last 7 days.` : ''}` },
{ icon: 'fa-heart', num: stats.follows, label: 'Follows', metric: 'follows', title: 'Channel follows', recent: R.follows },
{ icon: 'fa-heart', num: stats.follows, label: 'Follows', metric: 'follows', title: 'Channel follows', recent: R.follows },
{ key: 'chatMessages', icon: 'fa-comments', num: stats.chatMessages, label: 'Messages', metric: 'messages', title: 'Chat messages sent', recent: R.messages },
{ key: 'chatMessages', icon: 'fa-comments', num: stats.chatMessages, label: 'Messages', metric: 'messages', title: 'Chat messages sent', recent: R.messages },
- { key: 'hoursWatched', recent: R.hours, unit: 'h', icon: 'fa-couch', num: stats.hoursWatched, label: 'Hrs Watched', metric: 'hoursWatched', title: 'Hours the community has spent watching streams', unit: 'h' },
+ { key: 'hoursWatched', recent: R.hoursWatched, unit: 'h', icon: 'fa-couch', num: stats.hoursWatched, label: 'Hours watched', metric: 'hoursWatched', title: 'Hours people have spent watching streams here' },
],
],
});
});
// ── 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', metric: 'vibes', title: 'Vibes donated between people (100 Vibes = $1)', recent: R.vibes },
- { recent: R.supporters, 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-hand-holding-dollar', num: stats.vibesTipped, label: 'Vibes tipped', metric: 'vibes', title: 'Vibes tipped to streamers (100 Vibes = $1), on Live, PowerChat and OpenVibe.Tips', recent: R.vibes },
+ { recent: R.supporters, icon: 'fa-hand-holding-heart', num: stats.supporters, label: 'Supporters', metric: 'supporters', title: 'People who have tipped a streamer' },
+ { icon: 'fa-cart-shopping', num: stats.vibesBought, label: 'Vibes bought', metric: 'vibesBought', title: 'Vibes bought 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-star', num: stats.activeSubs, label: 'Subs', metric: 'subs', title: 'Active channel subscriptions', recent: R.subs },
- // OpenCoins are network-wide; channel points below are per channel. Shown only when
- // the wallet service answered — four zeroed chips would read as "nobody has any".
+ // OpenCoins are network-wide; channel points below are per channel. Shown only when the wallet service
+ // answered: zeroed chips would read as "nobody has any". (Coins held was coins earned less 50 spent: gone.)
...(stats.coinsEarned != null ? [
...(stats.coinsEarned != null ? [
- { recent: R.coinsEarned, icon: 'fa-circle-dollar-to-slot', num: stats.coinsEarned, label: 'Coins Earned', title: 'OpenCoins earned across the whole network — the site-wide currency you get for watching, chatting and using the tools' },
- { recent: R.coinsSpent, icon: 'fa-basket-shopping', num: stats.coinsSpent, label: 'Coins Spent', title: 'OpenCoins spent on emotes, themes, cosmetics and sounds' },
- { icon: 'fa-vault', num: stats.coinsCirculating, label: 'Coins Held', title: 'OpenCoins sitting in wallets right now, across every OpenVibe site' },
+ { recent: R.coinsEarned, icon: 'fa-circle-dollar-to-slot', num: stats.coinsEarned, label: 'Coins earned', title: 'OpenCoins earned across the whole network — the site-wide currency you get for watching, chatting and using the tools', desc: `OpenCoins earned across the whole network: the site-wide currency you get for watching, chatting and using the tools.${stats.coinsCirculating != null ? ` ${_fmtCount(stats.coinsCirculating)} are in wallets right now.` : ''}` },
+ { recent: R.coinsSpent, icon: 'fa-basket-shopping', num: stats.coinsSpent, label: 'Coins spent', title: 'OpenCoins spent on emotes, themes, cosmetics and sounds' },
{ recent: R.coinHolders, icon: 'fa-wallet', num: stats.coinHolders, label: 'Wallets', title: 'People holding OpenCoins' },
{ recent: R.coinHolders, icon: 'fa-wallet', num: stats.coinHolders, label: 'Wallets', title: 'People holding OpenCoins' },
] : []),
] : []),
- { icon: 'fa-coins', num: stats.pointsEarned, label: 'Points Earned', metric: 'points', title: 'Channel points earned by viewers (watching, chatting, following) — per channel, unlike OpenCoins', 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-coins', num: stats.pointsEarned, label: 'Points earned', metric: 'points', title: 'Channel points earned by viewers (watching, chatting, following) — per channel, unlike OpenCoins', 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` : '' },
{ recent: R.goals, 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` : '' },
{ recent: R.goals, 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` : '' },
],
],
});
});
@@ -333,11 +364,11 @@ function _renderHeroStats(stats) {
@@ -333,11 +364,11 @@ 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', metric: 'sessions', title: 'Total stream sessions', recent: R.sessions },
+ { key: 'liveSessions', icon: 'fa-tower-broadcast', num: stats.liveSessions, label: 'Streams', metric: 'sessions', title: 'Streams started here, all time', recent: R.sessions },
{ icon: 'fa-film', num: stats.vods, label: 'VODs', metric: 'vods', title: 'Recorded videos', recent: R.vods },
{ 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-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-clock', num: stats.streamHours, label: 'Hours archived', metric: 'hours', title: 'Hours of video kept in the archive', 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 },
// The emotes series is gone (OpenVibe.Chat owns the table; Live stopped reading it in T3
// The emotes series is gone (OpenVibe.Chat owns the table; Live stopped reading it in T3
// N+2), so the chip and its chart are skipped — `stats.emotes` is null, the same "no series"
// N+2), so the chip and its chart are skipped — `stats.emotes` is null, the same "no series"
// signal the OpenCoins chips use. No other markup changes.
// signal the OpenCoins chips use. No other markup changes.
@@ -363,14 +394,7 @@ function _renderHeroStats(stats) {
@@ -363,14 +394,7 @@ function _renderHeroStats(stats) {
* `rec` is { w, pw }: this window and the one before it. Either may be absent, and the pill
* `rec` is { w, pw }: this window and the one before it. Either may be absent, and the pill
* degrades to whichever half it has rather than disappearing.
* degrades to whichever half it has rather than disappearing.
*/
*/
- const trendBits = (w, pw) => {
- if (!Number.isFinite(pw)) return '';
- if (pw === 0) return w > 0 ? '<span class="hero-stat-trend is-new">new</span>' : '';
- const pct = Math.round(((w - pw) / pw) * 100);
- if (pct === 0) return '<span class="hero-stat-trend is-flat"><i></i>flat</span>';
- const up = pct > 0;
- return `<span class="hero-stat-trend ${up ? 'is-up' : 'is-down'}"><i></i>${Math.abs(pct)}%</span>`;
- };
+ const trendBits = _heroTrendBits;
/**
/**
* The two instantaneous readings — streams live and people watching — against what's normal.
* The two instantaneous readings — streams live and people watching — against what's normal.
*
*
@@ -392,7 +416,7 @@ function _renderHeroStats(stats) {
@@ -392,7 +416,7 @@ function _renderHeroStats(stats) {
+ '</span>';
+ '</span>';
};
};
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 (the sparkline)
const sub = r.sub || recSub(r.recent, r.unit || '');
const sub = r.sub || recSub(r.recent, r.unit || '');
const tip = {
const tip = {
label: r.label, title: r.title || '', desc: r.desc || '',
label: r.label, title: r.title || '', desc: r.desc || '',
@@ -415,13 +439,13 @@ function _renderHeroStats(stats) {
@@ -415,13 +439,13 @@ function _renderHeroStats(stats) {
const HEADLINE = [
const HEADLINE = [
{ key: 'liveNow', metric: 'liveNow', icon: stats.liveNow > 0 ? 'fa-circle' : 'fa-circle-dot', cls: stats.liveNow > 0 ? 'hero-stat--live' : '', num: stats.liveNow, label: 'Live', title: 'Streams live right now', deltaHTML: _baselineDeltaHTML(stats.liveNow, CC.liveAvg24h, CC.livePeak24h) },
{ key: 'liveNow', metric: 'liveNow', icon: stats.liveNow > 0 ? 'fa-circle' : 'fa-circle-dot', cls: stats.liveNow > 0 ? 'hero-stat--live' : '', num: stats.liveNow, label: 'Live', title: 'Streams live right now', deltaHTML: _baselineDeltaHTML(stats.liveNow, CC.liveAvg24h, CC.livePeak24h) },
{ key: 'viewersNow', metric: 'viewersNow', icon: 'fa-eye', cls: stats.viewersNow > 0 ? 'hero-stat--live' : '', num: stats.viewersNow, label: 'Watching', title: 'Viewers watching right now', deltaHTML: _baselineDeltaHTML(stats.viewersNow, CC.viewersAvg24h, CC.viewersPeak24h) },
{ key: 'viewersNow', metric: 'viewersNow', icon: 'fa-eye', cls: stats.viewersNow > 0 ? 'hero-stat--live' : '', num: stats.viewersNow, label: 'Watching', title: 'Viewers watching right now', deltaHTML: _baselineDeltaHTML(stats.viewersNow, CC.viewersAvg24h, CC.viewersPeak24h) },
- { key: 'weeklyActive', metric: 'active', icon: 'fa-fire', num: stats.weeklyActive, label: 'Active', title: 'People who chatted in the last 7 days', recent: { w: stats.weeklyActive, pw: stats.prevWeeklyActive } },
- { key: 'users', metric: 'users', icon: 'fa-user-group', num: stats.users, label: 'Users', title: 'Accounts on OpenVibe.Live', recent: R.users },
- { key: 'weeklyVisitors', metric: 'visitors', icon: 'fa-user-plus', num: stats.weeklyVisitors, label: 'Visitors', title: 'First-time visitors in the last 7 days', recent: { w: stats.weeklyVisitors, pw: stats.prevWeeklyVisitors } },
- { key: 'anons', metric: 'anons', icon: 'fa-user-secret', num: stats.anons, label: 'Anons', title: 'Anonymous chatters who have been given a name', recent: R.anons },
+ { key: 'weeklyActive', metric: 'active', icon: 'fa-fire', num: stats.weeklyActive, label: 'Chatted', title: 'People who chatted in the last 7 days', deltaHTML: _heroWindowDeltaHTML(stats.weeklyActive, stats.prevWeeklyActive) },
+ { key: 'anons', metric: 'visitors', icon: 'fa-user-plus', num: stats.anons, label: 'Visitors', title: 'Addresses that have visited Live (each is given an anon name)', recent: R.anons },
{ key: 'chatMessages', metric: 'messages', icon: 'fa-comments', num: stats.chatMessages, label: 'Messages', title: 'Chat messages sent, all time', recent: R.messages },
{ key: 'chatMessages', metric: 'messages', icon: 'fa-comments', num: stats.chatMessages, label: 'Messages', title: 'Chat messages sent, all time', recent: R.messages },
- { key: 'streamers', metric: 'streamers', icon: 'fa-satellite-dish', num: stats.streamers, label: 'Streamers', title: 'People who have gone live here', recent: R.streamers },
- { key: 'hoursWatched', metric: 'hoursWatched', icon: 'fa-couch', num: stats.hoursWatched, label: 'Hours', title: 'Hours the community has spent watching', recent: R.hours },
+ { key: 'streamers', metric: 'streamers', icon: 'fa-satellite-dish', num: stats.streamers, label: 'Streamers', title: 'People who have gone live here ("+" is new streamers)', recent: R.streamers },
+ { key: 'liveSessions', metric: 'sessions', icon: 'fa-tower-broadcast', num: stats.liveSessions, label: 'Streams', title: 'Streams started here, all time', recent: R.sessions },
+ { key: 'users', metric: 'users', icon: 'fa-user-group', num: stats.users, label: 'Accounts', title: 'People with an account on OpenVibe.Live', recent: R.users },
+ { key: 'hoursWatched', metric: 'hoursWatched', icon: 'fa-couch', num: stats.hoursWatched, label: 'Watch hrs', title: 'Hours people have spent watching streams here', recent: R.hoursWatched, unit: 'h' },
];
];
// Always starts collapsed. Persisting "expanded" meant every reload rendered and counted up
// Always starts collapsed. Persisting "expanded" meant every reload rendered and counted up
// two dozen extra chips before the page had finished loading, for a view the reader asked for
// two dozen extra chips before the page had finished loading, for a view the reader asked for
@@ -443,7 +467,7 @@ function _renderHeroStats(stats) {
@@ -443,7 +467,7 @@ function _renderHeroStats(stats) {
const btn = wrap.querySelector('#hero-stat-more');
const btn = wrap.querySelector('#hero-stat-more');
const full = wrap.querySelector('#hero-stat-full');
const full = wrap.querySelector('#hero-stat-full');
const strip = wrap.querySelector('.hero-stat-strip');
const strip = wrap.querySelector('.hero-stat-strip');
- const total = groups.reduce((n, g) => n + g.rows.length, 0);
+ const total = groups.reduce((n, g) => n + g.rows.filter(r => !r.spark).length, 0);
const label = () => { btn.querySelector('.hero-stat-more-text').textContent = full.hidden ? `Show all ${total} stats` : 'Show fewer stats'; };
const label = () => { btn.querySelector('.hero-stat-more-text').textContent = full.hidden ? `Show all ${total} stats` : 'Show fewer stats'; };
const sync = () => { strip.hidden = !full.hidden; btn.classList.toggle('is-open', !full.hidden); label(); };
const sync = () => { strip.hidden = !full.hidden; btn.classList.toggle('is-open', !full.hidden); label(); };
sync();
sync();
@@ -537,12 +561,16 @@ function _startHeroStatsLive() {
@@ -537,12 +561,16 @@ function _startHeroStatsLive() {
users: d.recent?.users, anons: d.recent?.anons,
users: d.recent?.users, anons: d.recent?.anons,
chatMessages: d.recent?.messages,
chatMessages: d.recent?.messages,
streamers: d.recent?.streamers,
streamers: d.recent?.streamers,
- // hoursWatched is deliberately absent: its 7-day figure comes from OpenVibe.Media
- // (that is where VODs live now), and this endpoint only knows the local number,
- // which is zero. Overwriting it here would wipe a correct value with a wrong one.
- weeklyActive: { w: d.weeklyActive, pw: d.prevWeeklyActive },
- weeklyVisitors: { w: d.weeklyVisitors, pw: d.prevWeeklyVisitors },
+ liveSessions: d.recent?.sessions,
+ hoursWatched: d.recent?.hoursWatched,
};
};
+ // A seven-day figure's pill is only its comparison with the week before: rebuilt whole when it changes.
+ const WINDOWED = { weeklyActive: [d.weeklyActive, d.prevWeeklyActive] };
+ for (const [key, [w, pw]] of Object.entries(WINDOWED)) {
+ const html = _heroWindowDeltaHTML(w, pw);
+ if (!html) continue;
+ wrap.querySelectorAll(`[data-stat="${key}"] .hero-stat-delta`).forEach(el => { if (el.outerHTML !== html) el.outerHTML = html; });
+ }
// The two instantaneous readings are re-rendered whole: their pill is a percentage
// The two instantaneous readings are re-rendered whole: their pill is a percentage
// against a moving baseline, not a running total, so patching one number isn't enough.
// against a moving baseline, not a running total, so patching one number isn't enough.
const cc = d.concurrency || {};
const cc = d.concurrency || {};
@@ -30,6 +30,9 @@
@@ -30,6 +30,9 @@
vibesBought: 'fa-cart-shopping', subs: 'fa-star', points: 'fa-coins', pointsSpent: 'fa-gift', redemptions: 'fa-gift',
vibesBought: 'fa-cart-shopping', subs: 'fa-star', points: 'fa-coins', pointsSpent: 'fa-gift', redemptions: 'fa-gift',
emotes: 'fa-face-grin-squint',
emotes: 'fa-face-grin-squint',
};
};
+ // Headcounts per day (people who chatted, people who tipped): the days do not add up, so these have no running
+ // total and no "added" figure; their chart is the daily count, and the chip's own number is the week's.
+ const DISTINCT = new Set(['active', 'supporters']);
const RANGES = [
const RANGES = [
{ days: 1, label: '24h', readingOnly: true },
{ days: 1, label: '24h', readingOnly: true },
{ days: 7, label: '7d' }, { days: 30, label: '30d' }, { days: 90, label: '90d' }, { days: 365, label: '1y' },
{ days: 7, label: '7d' }, { days: 30, label: '30d' }, { days: 90, label: '90d' }, { days: 365, label: '1y' },
@@ -91,6 +94,9 @@
@@ -91,6 +94,9 @@
state.lastFocus = document.activeElement;
state.lastFocus = document.activeElement;
const isReading = (m) => m.metric === 'liveNow' || m.metric === 'viewersNow';
const isReading = (m) => m.metric === 'liveNow' || m.metric === 'viewersNow';
state.days = isReading(state.metrics[state.index]) ? 7 : 30;
state.days = isReading(state.metrics[state.index]) ? 7 : 30;
+ // The 24-hour viewers line opens its own last 24 hours.
+ const asked = Number(chip?.dataset?.days);
+ if (RANGES.some((r) => r.days === asked && (!r.readingOnly || isReading(state.metrics[state.index])))) state.days = asked;
state.view = isReading(state.metrics[state.index]) ? 'avg' : 'daily';
state.view = isReading(state.metrics[state.index]) ? 'avg' : 'daily';
state.table = false;
state.table = false;
build();
build();
@@ -184,7 +190,8 @@
@@ -184,7 +190,8 @@
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 1.5) step(dx < 0 ? 1 : -1);
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 1.5) step(dx < 0 ? 1 : -1);
}, { passive: true });
}, { passive: true });
const plot = ov.querySelector('.sn-plot');
const plot = ov.querySelector('.sn-plot');
- state.ro = new ResizeObserver(() => { if (state.data && !state.table) drawChart(); });
+ // Only a chart that has points: an error answer has none, and redrawing it threw on every resize.
+ state.ro = new ResizeObserver(() => { if (state.data?.points && !state.table) drawChart(); });
state.ro.observe(plot);
state.ro.observe(plot);
renderHead();
renderHead();
ov.querySelector('.sn-dialog').focus({ preventScroll: true });
ov.querySelector('.sn-dialog').focus({ preventScroll: true });
@@ -210,7 +217,8 @@
@@ -210,7 +217,8 @@
}));
}));
const views = m.kind === 'reading'
const views = m.kind === 'reading'
? [['avg', 'Average'], ['peak', 'Peak']]
? [['avg', 'Average'], ['peak', 'Peak']]
- : [['daily', 'Per day'], ['total', 'Running total']];
+ : DISTINCT.has(m.metric) ? [['daily', 'Per day']] : [['daily', 'Per day'], ['total', 'Running total']];
+ if (DISTINCT.has(m.metric)) state.view = 'daily';
const viewSeg = ov.querySelector('.sn-views');
const viewSeg = ov.querySelector('.sn-views');
viewSeg.replaceChildren(...views.map(([k, label]) => {
viewSeg.replaceChildren(...views.map(([k, label]) => {
const b = el('button', k === state.view ? 'is-on' : '', label);
const b = el('button', k === state.view ? 'is-on' : '', label);
@@ -303,6 +311,13 @@
@@ -303,6 +311,13 @@
}
}
const best = d.points.reduce((a, p) => (p.value > a.value ? p : a), { value: -1 });
const best = d.points.reduce((a, p) => (p.value > a.value ? p : a), { value: -1 });
const avg = d.total / d.points.length;
const avg = d.total / d.points.length;
+ if (DISTINCT.has(m.metric)) {
+ return [
+ ['Last 7 days', exact(m.now, unit)],
+ [`Average day · ${rangeName()}`, exact(avg, unit)],
+ ['Busiest day', best.value > 0 ? exact(best.value, unit) : '—', best.value > 0 ? el('span', 'sn-tile-sub', dayLabel(best.day)) : null],
+ ];
+ }
const tiles = [
const tiles = [
[`Added · ${rangeName()}`, exact(d.total, unit), deltaPill(d.total, d.prev_total)],
[`Added · ${rangeName()}`, exact(d.total, unit), deltaPill(d.total, d.prev_total)],
['Per day', exact(avg, unit)],
['Per day', exact(avg, unit)],
@@ -484,7 +499,8 @@
@@ -484,7 +499,8 @@
const wrap = ov.querySelector('.sn-table');
const wrap = ov.querySelector('.sn-table');
const t = el('table');
const t = el('table');
const head = el('tr');
const head = el('tr');
- const cols = d.kind === 'reading' ? [d.bucket === 'hour' ? 'Hour' : 'Day', 'Average', 'Peak'] : ['Day', 'Added', 'Running total'];
+ const distinct = DISTINCT.has(current().metric);
+ const cols = d.kind === 'reading' ? [d.bucket === 'hour' ? 'Hour' : 'Day', 'Average', 'Peak'] : distinct ? ['Day', 'People'] : ['Day', 'Added', 'Running total'];
cols.forEach((c) => head.append(el('th', null, c)));
cols.forEach((c) => head.append(el('th', null, c)));
const thead = el('thead'); thead.append(head);
const thead = el('thead'); thead.append(head);
const tbody = el('tbody');
const tbody = el('tbody');
@@ -494,6 +510,8 @@
@@ -494,6 +510,8 @@
tr.append(el('td', null, bucketLabel(p)));
tr.append(el('td', null, bucketLabel(p)));
if (d.kind === 'reading') {
if (d.kind === 'reading') {
tr.append(el('td', 'num', exact(p.value, unit)), el('td', 'num', exact(p.peak, unit)));
tr.append(el('td', 'num', exact(p.value, unit)), el('td', 'num', exact(p.peak, unit)));
+ } else if (distinct) {
+ tr.append(el('td', 'num', exact(p.value, unit)));
} else {
} else {
run += p.value;
run += p.value;
tr.append(el('td', 'num', exact(p.value, unit)), el('td', 'num', exact(run, unit)));
tr.append(el('td', 'num', exact(p.value, unit)), el('td', 'num', exact(run, unit)));
@@ -508,10 +526,12 @@
@@ -508,10 +526,12 @@
function downloadCsv() {
function downloadCsv() {
const d = state.data; if (!d || !d.points) return;
const d = state.data; if (!d || !d.points) return;
const m = current();
const m = current();
- const lines = [d.kind === 'reading' ? 'bucket,average,peak' : 'day,value,running_total'];
+ const distinct = DISTINCT.has(m.metric);
+ const lines = [d.kind === 'reading' ? 'bucket,average,peak' : distinct ? 'day,people' : 'day,value,running_total'];
let run = Number(d.before) || 0;
let run = Number(d.before) || 0;
for (const p of d.points) {
for (const p of d.points) {
if (d.kind === 'reading') lines.push(`${p.t},${p.value ?? ''},${p.peak ?? ''}`);
if (d.kind === 'reading') lines.push(`${p.t},${p.value ?? ''},${p.peak ?? ''}`);
+ else if (distinct) lines.push(`${p.day},${p.value}`);
else { run += p.value; lines.push(`${p.day},${p.value},${Math.round(run * 100) / 100}`); }
else { run += p.value; lines.push(`${p.day},${p.value},${Math.round(run * 100) / 100}`); }
}
}
const blob = new Blob([lines.join('\n') + '\n'], { type: 'text/csv' });
const blob = new Blob([lines.join('\n') + '\n'], { type: 'text/csv' });
@@ -1525,6 +1525,8 @@ async function vibesStatsSince() {
@@ -1525,6 +1525,8 @@ async function vibesStatsSince() {
// One supporter in channel_tips, with or without a Live account (PowerChat and OpenVibe.Tips supporters have a name only).
// One supporter in channel_tips, with or without a Live account (PowerChat and OpenVibe.Tips supporters have a name only).
const TIP_SUPPORTER = "COALESCE(from_user_id::text, 'n:' || lower(COALESCE(username, '')))";
const TIP_SUPPORTER = "COALESCE(from_user_id::text, 'n:' || lower(COALESCE(username, '')))";
const TIP_SUPPORTER_T = "COALESCE(t.from_user_id::text, 'n:' || lower(COALESCE(t.username, '')))";
const TIP_SUPPORTER_T = "COALESCE(t.from_user_id::text, 'n:' || lower(COALESCE(t.username, '')))";
+// Each person's first stream: when they became a streamer.
+const FIRST_STREAMS = 'SELECT user_id, MIN(created_at) AS created_at FROM streams WHERE user_id IS NOT NULL GROUP BY user_id';
const HOME_SERIES = {
const HOME_SERIES = {
users: { table: 'users', ts: 'created_at', agg: 'COUNT(*)', where: 'COALESCE(is_banned, 0) = 0' },
users: { table: 'users', ts: 'created_at', agg: 'COUNT(*)', where: 'COALESCE(is_banned, 0) = 0' },
anons: { table: 'anon_ip_mappings', ts: 'created_at', agg: 'COUNT(*)' },
anons: { table: 'anon_ip_mappings', ts: 'created_at', agg: 'COUNT(*)' },
@@ -1534,13 +1536,16 @@ const HOME_SERIES = {
@@ -1534,13 +1536,16 @@ const HOME_SERIES = {
// getHomeStatSeries from Chat's `site-daily` stats read; there is no Live-table fallback any more
// getHomeStatSeries from Chat's `site-daily` stats read; there is no Live-table fallback any more
// (since 2026-10-05 Live keeps no copy of Chat's tables), so outside chat mode they answer null.
// (since 2026-10-05 Live keeps no copy of Chat's tables), so outside chat mode they answer null.
sessions: { table: 'streams', ts: 'created_at', agg: 'COUNT(*)' },
sessions: { table: 'streams', ts: 'created_at', agg: 'COUNT(*)' },
- streamers: { table: 'streams', ts: 'created_at', agg: 'COUNT(DISTINCT user_id)' },
+ // New streamers: each person counted once, on the day of their first stream, so the running total is the chip's
+ // all-time headcount. (Summing each day's distinct streamers counted a regular once per day they streamed.)
+ streamers: { table: `(${FIRST_STREAMS}) first_streams`, ts: 'created_at', agg: 'COUNT(*)' },
// vods, clips, hours and pastes are OpenVibe.Media's series (home/routes.js MEDIA_SERIES).
// vods, clips, hours and pastes are OpenVibe.Media's series (home/routes.js MEDIA_SERIES).
hoursWatched:{ table: 'watch_time', ts: 'created_at', agg: 'COALESCE(SUM(minutes_watched)::float8, 0) / 60.0' },
hoursWatched:{ table: 'watch_time', ts: 'created_at', agg: 'COALESCE(SUM(minutes_watched)::float8, 0) / 60.0' },
aiMoments: { table: 'stream_memories', ts: 'created_at', agg: 'COUNT(*)' },
aiMoments: { table: 'stream_memories', ts: 'created_at', agg: 'COUNT(*)' },
// Tips from channel_tips (every rail: on-site Vibes, PowerChat, OpenVibe.Tips), not the money ledger.
// Tips from channel_tips (every rail: on-site Vibes, PowerChat, OpenVibe.Tips), not the money ledger.
vibes: { table: 'channel_tips', ts: 'created_at', agg: 'COALESCE(SUM(amount)::float8, 0)', vibesReset: true },
vibes: { table: 'channel_tips', ts: 'created_at', agg: 'COALESCE(SUM(amount)::float8, 0)', vibesReset: true },
- supporters: { table: 'channel_tips', ts: 'created_at', agg: `COUNT(DISTINCT ${TIP_SUPPORTER})`, vibesReset: true },
+ // A headcount per day (`distinct`): the days do not add up, so the chart offers no running total.
+ supporters: { table: 'channel_tips', ts: 'created_at', agg: `COUNT(DISTINCT ${TIP_SUPPORTER})`, vibesReset: true, distinct: true },
// vibesBought and subs are OpenVibe.Billing's series (home/routes.js, monetization/billing-stats.js).
// vibesBought and subs are OpenVibe.Billing's series (home/routes.js, monetization/billing-stats.js).
points: { table: 'coin_transactions', ts: 'created_at', agg: 'COALESCE(SUM(amount)::float8, 0)', where: 'amount > 0' },
points: { table: 'coin_transactions', ts: 'created_at', agg: 'COALESCE(SUM(amount)::float8, 0)', where: 'amount > 0' },
pointsSpent: { table: 'coin_transactions', ts: 'created_at', agg: 'COALESCE(-SUM(amount)::float8, 0)', where: 'amount < 0' },
pointsSpent: { table: 'coin_transactions', ts: 'created_at', agg: 'COALESCE(-SUM(amount)::float8, 0)', where: 'amount < 0' },
@@ -1579,10 +1584,12 @@ async function homeSeriesLocal(metric, days = 30) {
@@ -1579,10 +1584,12 @@ async function homeSeriesLocal(metric, days = 30) {
const w = [...(def.where ? [def.where] : []), ...(def.vibesReset ? [`${def.ts} >= '${await vibesStatsSince()}'`] : []), extra];
const w = [...(def.where ? [def.where] : []), ...(def.vibesReset ? [`${def.ts} >= '${await vibesStatsSince()}'`] : []), extra];
try { return Number((await get(`SELECT ${def.agg} AS value FROM ${def.table} WHERE ${w.join(' AND ')}`, params))?.value) || 0; } catch { return 0; }
try { return Number((await get(`SELECT ${def.agg} AS value FROM ${def.table} WHERE ${w.join(' AND ')}`, params))?.value) || 0; } catch { return 0; }
};
};
- const before = await scalar(`${def.ts} < datetime('now', ?)`, [`-${days - 1} days`]);
+ // Rows with no timestamp (most anon_ip_mappings rows predate the column) are part of everything before the window,
+ // so the running total ends at the chip's all-time number.
+ const before = await scalar(`(${def.ts} < datetime('now', ?) OR ${def.ts} IS NULL)`, [`-${days - 1} days`]);
const prevTotal = await scalar(`${def.ts} >= datetime('now', ?) AND ${def.ts} < datetime('now', ?)`, [`-${2 * days - 1} days`, `-${days - 1} days`]);
const prevTotal = await scalar(`${def.ts} >= datetime('now', ?) AND ${def.ts} < datetime('now', ?)`, [`-${2 * days - 1} days`, `-${days - 1} days`]);
return { metric, kind: 'count', days, points, total, peak: Math.max(0, ...points.map(p => p.value)),
return { metric, kind: 'count', days, points, total, peak: Math.max(0, ...points.map(p => p.value)),
- before: Number(before.toFixed(2)), prev_total: Number(prevTotal.toFixed(2)) };
+ before: Number(before.toFixed(2)), prev_total: Number(prevTotal.toFixed(2)), ...(def.distinct ? { distinct: true } : {}) };
}
}
/**
/**
@@ -1593,9 +1600,11 @@ async function homeSeriesLocal(metric, days = 30) {
@@ -1593,9 +1600,11 @@ async function homeSeriesLocal(metric, days = 30) {
*/
*/
async function getHomeStatSeries(metric, days = 30) {
async function getHomeStatSeries(metric, days = 30) {
if (metric === 'messages' || metric === 'active') {
if (metric === 'messages' || metric === 'active') {
+ // Awaited, not peeked: a peek is null on every cold key (each range, each new UTC day), and the chart answered
+ // "Unknown metric" for it. `active` is a headcount per day, so its days do not add up (`distinct`).
try {
try {
- const s = await require('../chat/chat-reads').homeSeriesPeek(metric, days);
- if (s) return s;
+ const s = await require('../chat/chat-reads').homeSeries(metric, days);
+ if (s) return metric === 'active' ? { ...s, distinct: true } : s;
} catch { /* fall through to Live's own tables */ }
} catch { /* fall through to Live's own tables */ }
}
}
return await homeSeriesLocal(metric, days);
return await homeSeriesLocal(metric, days);
@@ -1675,10 +1684,13 @@ async function _computeHomeStats() {
@@ -1675,10 +1684,13 @@ async function _computeHomeStats() {
const prev = await c(`SELECT COALESCE(SUM(${col})::float8, 0) AS count FROM ${table} WHERE ${tsCol} >= datetime('now', '-14 days') AND ${tsCol} < datetime('now', '-7 days')${extra ? ' AND ' + extra : ''}`);
const prev = await c(`SELECT COALESCE(SUM(${col})::float8, 0) AS count FROM ${table} WHERE ${tsCol} >= datetime('now', '-14 days') AND ${tsCol} < datetime('now', '-7 days')${extra ? ' AND ' + extra : ''}`);
return { d: await q('-1 day'), w: await q('-7 days'), m: await q('-30 days'), pw: prev };
return { d: await q('-1 day'), w: await q('-7 days'), m: await q('-30 days'), pw: prev };
};
};
- // Distinct people who went live in a window — "streamers" is a headcount, not a stream count.
- const streamersWin = async () => {
- const q = async (a, b) => await c(`SELECT COUNT(DISTINCT user_id) AS count FROM streams WHERE user_id IS NOT NULL AND created_at >= datetime('now', ?)${b ? " AND created_at < datetime('now', ?)" : ''}`, b ? [a, b] : [a]);
- return { d: await q('-1 day'), w: await q('-7 days'), m: await q('-30 days'), pw: await q('-14 days', '-7 days') };
+ // New streamers in a window: people whose first stream falls in it, so "+N" on the chip adds to its headcount
+ // (and matches the chart). Distinct people who streamed in the window is `streamedWeek`.
+ const streamersWin = async () => await winCount(`(${FIRST_STREAMS}) first_streams`, 'created_at');
+ // Hours watched in a window (watch-time heartbeats are minutes).
+ const hoursWin = async () => {
+ const m = await winSum('watch_time', 'minutes_watched', 'created_at');
+ return { d: Math.round(m.d / 60), w: Math.round(m.w / 60), m: Math.round(m.m / 60), pw: Math.round(m.pw / 60) };
};
};
return {
return {
// ── Right-now + community-economy metrics ────────────────
// ── Right-now + community-economy metrics ────────────────
@@ -1708,6 +1720,9 @@ async function _computeHomeStats() {
@@ -1708,6 +1720,9 @@ async function _computeHomeStats() {
clips: null,
clips: null,
liveSessions: await c(`SELECT COUNT(*) AS count FROM streams`),
liveSessions: await c(`SELECT COUNT(*) AS count FROM streams`),
streamers: await c(`SELECT COUNT(DISTINCT user_id) AS count FROM streams WHERE user_id IS NOT NULL`),
streamers: await c(`SELECT COUNT(DISTINCT user_id) AS count FROM streams WHERE user_id IS NOT NULL`),
+ // Distinct people who went live in the last seven days, and the seven before.
+ streamedWeek: await c(`SELECT COUNT(DISTINCT user_id) AS count FROM streams WHERE user_id IS NOT NULL AND created_at >= datetime('now', '-7 days')`),
+ prevStreamedWeek: await c(`SELECT COUNT(DISTINCT user_id) AS count FROM streams WHERE user_id IS NOT NULL AND created_at >= datetime('now', '-14 days') AND created_at < datetime('now', '-7 days')`),
// OpenVibe.Chat owns chat_messages (roadmap T3): the total comes from Chat's read API.
// OpenVibe.Chat owns chat_messages (roadmap T3): the total comes from Chat's read API.
// A Chat outage answers the cache, else null — never a 500 for the home page.
// A Chat outage answers the cache, else null — never a 500 for the home page.
chatMessages: await (async () => { try { const s = await require('../chat/chat-reads').siteStatsPeek(); return s && s.messages != null ? s.messages : null; } catch { return null; } })(),
chatMessages: await (async () => { try { const s = await require('../chat/chat-reads').siteStatsPeek(); return s && s.messages != null ? s.messages : null; } catch { return null; } })(),
@@ -1758,6 +1773,7 @@ async function _computeHomeStats() {
@@ -1758,6 +1773,7 @@ async function _computeHomeStats() {
})(),
})(),
hours: null, // OpenVibe.Media
hours: null, // OpenVibe.Media
streamers: await streamersWin(),
streamers: await streamersWin(),
+ hoursWatched: await hoursWin(),
// Live's `emotes` copy was unread since N+2 and is now dropped, so the
// Live's `emotes` copy was unread since N+2 and is now dropped, so the
// deltas are zero. The key stays for the N-1 client, which reads stats.recent.emotes.
// deltas are zero. The key stays for the N-1 client, which reads stats.recent.emotes.
emotes: { d: 0, w: 0, m: 0, pw: 0 },
emotes: { d: 0, w: 0, m: 0, pw: 0 },
@@ -197,6 +197,8 @@ router.get('/stats/series/:metric', async (req, res) => {
@@ -197,6 +197,8 @@ router.get('/stats/series/:metric', async (req, res) => {
} else {
} else {
series = await db.getReadingSeries(metric, days) || await db.getHomeStatSeries(metric, days);
series = await db.getReadingSeries(metric, days) || await db.getHomeStatSeries(metric, days);
}
}
+ // A metric this route knows that has no answer right now (Chat's two, while Chat is away) is unavailable, not unknown.
+ if (!series && db.HOME_SERIES_KEYS.includes(metric)) return res.status(503).json({ error: 'This stat is unavailable right now. Try again in a minute.' });
if (!series) return res.status(404).json({ error: 'Unknown metric', metrics: [...db.HOME_SERIES_KEYS, ...MEDIA_SERIES, 'pastes', 'vibesBought', 'subs'] });
if (!series) return res.status(404).json({ error: 'Unknown metric', metrics: [...db.HOME_SERIES_KEYS, ...MEDIA_SERIES, 'pastes', 'vibesBought', 'subs'] });
if (_seriesCache.size > 200) _seriesCache.clear();
if (_seriesCache.size > 200) _seriesCache.clear();
_seriesCache.set(key, { at: Date.now(), data: series });
_seriesCache.set(key, { at: Date.now(), data: series });
@@ -297,7 +299,7 @@ router.get('/stats-live', async (req, res) => {
@@ -297,7 +299,7 @@ router.get('/stats-live', async (req, res) => {
const data = {
const data = {
liveNow: s.liveNow, viewersNow: s.viewersNow, weeklyActive: s.weeklyActive,
liveNow: s.liveNow, viewersNow: s.viewersNow, weeklyActive: s.weeklyActive,
users: s.users, weeklyVisitors: s.weeklyVisitors, anons: s.anons, chatMessages: s.chatMessages,
users: s.users, weeklyVisitors: s.weeklyVisitors, anons: s.anons, chatMessages: s.chatMessages,
- streamers: s.streamers, hoursWatched: s.hoursWatched,
+ streamers: s.streamers, hoursWatched: s.hoursWatched, liveSessions: s.liveSessions,
// The 7-day deltas the hero chips show under their numbers. Same shape as the full
// The 7-day deltas the hero chips show under their numbers. Same shape as the full
// board's `recent`, trimmed to the four that have one.
// board's `recent`, trimmed to the four that have one.
prevWeeklyVisitors: s.prevWeeklyVisitors, prevWeeklyActive: s.prevWeeklyActive,
prevWeeklyVisitors: s.prevWeeklyVisitors, prevWeeklyActive: s.prevWeeklyActive,
@@ -308,9 +310,9 @@ router.get('/stats-live', async (req, res) => {
@@ -308,9 +310,9 @@ router.get('/stats-live', async (req, res) => {
users: s.recent?.users, anons: s.recent?.anons,
users: s.recent?.users, anons: s.recent?.anons,
messages: s.recent?.messages,
messages: s.recent?.messages,
streamers: s.recent?.streamers,
streamers: s.recent?.streamers,
- // No `hours` here on purpose — the real figure comes from OpenVibe.Media and is
- // merged into the hero payload; the local number is zero and would be wrong.
-
+ sessions: s.recent?.sessions,
+ hoursWatched: s.recent?.hoursWatched,
+ // No `hours` (hours archived) here: that one is OpenVibe.Media's, merged into the hero payload only.
},
},
};
};
_liveStats = { at: Date.now(), data };
_liveStats = { at: Date.now(), data };
@@ -855,11 +855,16 @@ const waitFor = async (pred, what, ms = 3000) => {
@@ -855,11 +855,16 @@ const waitFor = async (pred, what, ms = 3000) => {
const activeSeries = chatReads.homeSeriesPeek('active', 7);
const activeSeries = chatReads.homeSeriesPeek('active', 7);
assert.strictEqual(activeSeries.points[4].value, 2, 'two days ago: two distinct chatters');
assert.strictEqual(activeSeries.points[4].value, 2, 'two days ago: two distinct chatters');
assert.strictEqual(activeSeries.points[5].value, 2, 'yesterday: two distinct chatters');
assert.strictEqual(activeSeries.points[5].value, 2, 'yesterday: two distinct chatters');
- // getHomeStatSeries routes the two chat metrics through the chat series.
- const realSeriesPeek = chatReads.homeSeriesPeek;
- chatReads.homeSeriesPeek = () => ({ metric: 'messages', sentinel: true });
- try { assert.strictEqual((await d2.getHomeStatSeries('messages', 7)).sentinel, true, 'getHomeStatSeries reads the chat series in chat mode'); }
- finally { chatReads.homeSeriesPeek = realSeriesPeek; }
+ // getHomeStatSeries routes the two chat metrics through the chat series, awaited: a peek was null on every
+ // cold key (each range, each new UTC day), and the chart answered "Unknown metric". `active` is a daily
+ // headcount, marked `distinct` (its days do not add up).
+ const realSeries = chatReads.homeSeries;
+ chatReads.homeSeries = async (metric) => ({ metric, sentinel: true });
+ try {
+ assert.strictEqual((await d2.getHomeStatSeries('messages', 7)).sentinel, true, 'getHomeStatSeries reads the chat series in chat mode');
+ assert.strictEqual((await d2.getHomeStatSeries('messages', 7)).distinct, undefined, 'messages add up');
+ assert.strictEqual((await d2.getHomeStatSeries('active', 7)).distinct, true, 'active is a daily headcount');
+ } finally { chatReads.homeSeries = realSeries; }
// A cold cache during a Chat outage answers null (Live keeps no chat series to fall back to).
// A cold cache during a Chat outage answers null (Live keeps no chat series to fall back to).
chatReads._reset();
chatReads._reset();
readState.down = true;
readState.down = true;
new file mode 100644
new file mode 100644
@@ -0,0 +1,54 @@
@@ -0,0 +1,54 @@
+/**
+ * The home page's stat board (public/js/app-home.js) and its charts (public/js/stats-nerds.js), after the 2026-10-27
+ * review: every number appears once, a seven-day figure's pill is only its comparison with the week before, and a
+ * daily headcount's chart has no running total.
+ *
+ * node test/home-stat-board.test.js
+ */
+'use strict';
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+
+const src = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'app-home.js'), 'utf8');
+const fnSource = (name) => {
+ const start = src.indexOf(`function ${name}(`);
+ assert.ok(start >= 0, name);
+ let i = src.indexOf('{', start), depth = 0;
+ for (; i < src.length; i++) { if (src[i] === '{') depth++; else if (src[i] === '}' && --depth === 0) break; }
+ return src.slice(start, i + 1);
+};
+// eslint-disable-next-line no-new-func
+const windowDelta = new Function('_fmtCount', 'esc', `${fnSource('_heroTrendBits')}\n${fnSource('_heroWindowDeltaHTML')}; return _heroWindowDeltaHTML;`)(String, (s) => String(s));
+
+// "+66 7d" under a chip reading 66 said the number twice: the pill is only the comparison.
+const pill = windowDelta(66, 68);
+assert.match(pill, /is-window is-down/);
+assert.match(pill, /<em>7d<\/em><span class="hero-stat-trend is-down"><i><\/i>3%<\/span>/);
+assert.ok(!/\+66/.test(pill), 'the week\'s own number is not repeated');
+assert.match(windowDelta(5, 0), /is-new/, 'nothing the week before: new');
+assert.strictEqual(windowDelta(5, undefined), '', 'no previous week: no pill');
+assert.match(windowDelta(4, 4), /is-flat/);
+
+// The board: each chart metric once, and visitors are one chip (the same table was "Visitors" and "Anons").
+// (The 24-hour viewers line opens the Watching chart too: it is a picture of that metric, not another number.)
+const boardAll = src.slice(src.indexOf('// ── Right now ──'), src.indexOf('// Rolling-window deltas'));
+const board = boardAll.slice(0, boardAll.indexOf('const trend = Array.isArray')) + boardAll.slice(boardAll.indexOf("groups.push({ kicker: 'Right now'"));
+const metrics = [...board.matchAll(/metric: '([a-zA-Z]+)'/g)].map((m) => m[1]);
+const twice = metrics.filter((m, i) => metrics.indexOf(m) !== i);
+assert.deepStrictEqual(twice, [], `each metric once on the board (twice: ${twice.join(', ')})`);
+assert.ok(metrics.includes('visitors') && !metrics.includes('anons'), 'one visitors chip');
+assert.ok(!/label: 'Coins Held'|label: 'Coins held'/.test(board), 'coins held was coins earned less 50: gone');
+assert.ok(board.includes("label: 'Hours watched'") && board.includes("label: 'Hours archived'"), 'the two hours say which');
+const strip = src.slice(src.indexOf('const HEADLINE = ['), src.indexOf('];', src.indexOf('const HEADLINE = [')));
+const stripMetrics = [...strip.matchAll(/metric: '([a-zA-Z]+)'/g)].map((m) => m[1]);
+assert.strictEqual(new Set(stripMetrics).size, stripMetrics.length, 'the strip shows each metric once');
+assert.strictEqual(stripMetrics.length, 9, 'nine chips: three rows of three on a phone');
+
+// The chart dialog: daily headcounts have no running total, and an error answer is never redrawn.
+const nerds = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'stats-nerds.js'), 'utf8');
+assert.ok(/const DISTINCT = new Set\(\['active', 'supporters'\]\)/.test(nerds));
+assert.ok(nerds.includes("DISTINCT.has(m.metric) ? [['daily', 'Per day']]"), 'a headcount offers only per day');
+assert.ok(nerds.includes('if (state.data?.points && !state.table) drawChart();'), 'a resize never redraws an error answer');
+assert.ok(nerds.includes('const asked = Number(chip?.dataset?.days);'), 'the 24-hour line opens its own 24 hours');
+console.log('home stat board: all checks passed');
@@ -85,5 +85,32 @@ await db.setSetting('stats_vibes_reset_at', 'garbage');
@@ -85,5 +85,32 @@ await db.setSetting('stats_vibes_reset_at', 'garbage');
assert.strictEqual(await db.vibesStatsSince(), '1970-01-01 00:00:00', 'an unparseable reset value is ignored, not fatal');
assert.strictEqual(await 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('✅ Vibes reset cutoff applies to counters, deltas and series');
+// ── 2026-10-27 review: new streamers, visitors' running total, hours watched per window ──
+// Bob streams three days in a row and Alice once, 2 days ago: the chart counts each person once, on their first
+// stream, so its running total is the chip's headcount (summing each day's streamers counted Bob three times).
+for (const back of [5, 4, 3]) await db.run("INSERT INTO streams (user_id, title, created_at) VALUES (?, 'b', datetime('now', ?))", [b, `-${back} days`]);
+await db.run("INSERT INTO streams (user_id, title, created_at) VALUES (?, 'a', datetime('now', '-2 days'))", [a]);
+const streamers7 = await db.getHomeStatSeries('streamers', 7);
+assert.strictEqual(streamers7.total, 2, 'two new streamers in the week, Bob once');
+assert.strictEqual(streamers7.points[6 - 5].value, 1, 'Bob on his first day only');
+assert.strictEqual(streamers7.points[6 - 4].value, 0);
+assert.strictEqual(streamers7.before + streamers7.total, 2, 'the running total ends at the headcount');
+// Most anon rows predate created_at: they are part of everything before the window, so the running total ends at
+// the chip's number (it said 1,812 against 29,144 on production).
+await db.run("INSERT INTO anon_ip_mappings (ip, anon_num, created_at) VALUES ('10.0.0.1', 901, NULL), ('10.0.0.2', 902, NULL), ('10.0.0.3', 903, datetime('now', '-1 hours'))");
+const visitors7 = await db.getHomeStatSeries('visitors', 7);
+assert.strictEqual(visitors7.total, 1);
+assert.strictEqual(visitors7.before, 2, 'rows with no date count before the window');
+// Hours watched over each window, and distinct streamers this week.
+await db.run("INSERT INTO watch_time (user_id, stream_id, minutes_watched, created_at) SELECT ?, id, 120, datetime('now', '-2 hours') FROM streams WHERE user_id = ? LIMIT 1", [a, b]);
+await db.run("INSERT INTO watch_time (user_id, stream_id, minutes_watched, created_at) SELECT ?, id, 180, datetime('now', '-10 days') FROM streams WHERE user_id = ? LIMIT 1", [b, a]);
+stats = await db._computeHomeStats();
+assert.deepStrictEqual(stats.recent.hoursWatched, { d: 2, w: 2, m: 5, pw: 3 }, 'hours watched per window, and the week before');
+assert.deepStrictEqual(stats.recent.streamers, { d: 0, w: 2, m: 2, pw: 0 }, '"+" on Streamers is new streamers');
+assert.strictEqual(stats.streamedWeek, 2, 'two people went live this week');
+assert.strictEqual((await db.getHomeStatSeries('supporters', 7)).distinct, true, 'supporters per day do not add up');
+assert.strictEqual((await db.getHomeStatSeries('users', 7)).distinct, undefined);
+console.log('✅ new streamers, visitors before the window, hours watched per window');
+
console.log('\n✅ All home-stats tests passed');
console.log('\n✅ All home-stats tests passed');
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((e) => { console.error(e); process.exit(1); });