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)

OP OpenVibers committed 17 days ago · 65478fe461 · 9 files, +258 −69

Changes

ddiff --git a/public/css/features/home.css b/public/css/features/home.css
iindex eba3b8a6..959362e4 100644
---- a/public/css/features/home.css
++++ b/public/css/features/home.css
@@@ -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; } }
ddiff --git a/public/css/features/stats.css b/public/css/features/stats.css
iindex 031ec49b..d4382db4 100644
---- a/public/css/features/stats.css
++++ b/public/css/features/stats.css
@@@ -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; } }
ddiff --git a/public/js/app-home.js b/public/js/app-home.js
iindex 31a89304..43311ef5 100644
---- a/public/js/app-home.js
++++ b/public/js/app-home.js
@@@ -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 || {};
ddiff --git a/public/js/stats-nerds.js b/public/js/stats-nerds.js
iindex 9b91a058..c286f3bb 100644
---- a/public/js/stats-nerds.js
++++ b/public/js/stats-nerds.js
@@@ -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' });
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 79a16849..b99cab83 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -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.Media
streamers: 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 },
ddiff --git a/server/home/routes.js b/server/home/routes.js
iindex d8be3bd5..c155ae3d 100644
---- a/server/home/routes.js
++++ b/server/home/routes.js
@@@ -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 };
ddiff --git a/test/chat-context.test.js b/test/chat-context.test.js
iindex 4acb99c8..1a7b43b7 100644
---- a/test/chat-context.test.js
++++ b/test/chat-context.test.js
@@@ -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;
ddiff --git a/test/home-stat-board.test.js b/test/home-stat-board.test.js
nnew file mode 100644
iindex 00000000..867f45d4
---- /dev/null
++++ b/test/home-stat-board.test.js
@@@ -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');
ddiff --git a/test/home-stats-series.test.js b/test/home-stats-series.test.js
iindex d31f7aae..1d667d6b 100644
---- a/test/home-stats-series.test.js
++++ b/test/home-stats-series.test.js
@@@ -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); });