openvibers/openvibe.live Commits
Hero: fix the icon tiles for real, and give Live/Watching a baseline to compare against
Changes
@@@ -2342,7 +2342,7 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }border: 1px solid color-mix(in srgb, var(--accent) 24%, transparent); backdrop-filter: blur(8px);box-shadow: 0 6px 20px -12px rgba(0, 0, 0, 0.7);}--.hero-stat > i { color: var(--accent); font-size: 1.05rem; }++.hero-stat > i { color: var(--accent); }.hero-stat-meta { display: flex; flex-direction: column; align-items: flex-start; line-height: 1.05; min-width: 0; }.hero-stat-label, .hero-stat-sub { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }.hero-stat-num { font-size: 1.22rem; font-weight: 800; color: var(--text-primary); font-variant-numeric: tabular-nums; }@@@ -2367,7 +2367,8 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }.hero-stat-kicker { padding-left: 10px; border-left-width: 2px; }.hero-stat-row { grid-template-columns: repeat(auto-fit, minmax(104px, 1fr)); gap: 6px; margin-left: 10px; }.hero-stat { padding: 7px 9px; gap: 7px; }-- .hero-stat > i { display: none; } /* icons cost the room labels need at 3-up */++ /* (The old rule here hid the icon at 3-up to make room for labels. The tile design sizes++ itself off the chip now and the labels are short words, so both fit.) */.hero-stat-label { white-space: normal; line-height: 1.1; }.hero-stat-num { font-size: 1.05rem; }.hero-float { border-radius: 10px; }@@@ -14103,8 +14104,8 @@ label.staff-inline-toggle.aiv-field { flex-direction:row; align-items:center; }.hero-chart-foot b { color: var(--text-primary); }.hero-chart-loading { padding: 60px 0; text-align: center; color: var(--text-muted); }@media (max-width: 720px) {-- /* Icons back on phones — smaller, and the label wraps instead of hiding them. */-- .hero-stat > i { display: inline-block !important; font-size: 0.85rem; }++ /* (The counterpart !important rule lived here. It forced display:inline-block, which beat the++ tile's grid centring and left every glyph sitting off-centre.) */.hero-stat { padding: 7px 8px; gap: 6px; }.hero-stat-label { white-space: normal; line-height: 1.15; }.hero-stat-chart-hint { opacity: 0.6; }@@@ -14818,7 +14819,6 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srgmax-width: 940px; margin: 0 auto; width: 100%;}.hero-stat-strip .hero-stat { min-width: 0; padding: clamp(6px, 1.1vw, 10px) clamp(7px, 1.2vw, 12px); gap: clamp(5px, 0.9vw, 10px); }--.hero-stat-strip .hero-stat > i { font-size: clamp(0.72rem, 1.6vw, 1.05rem); }/* Sized against the chip, not the viewport: at three per row a chip is roughly a third of thewidth minus gaps, so 3.2vw tracks the space actually available and clamps at both ends. */.hero-stat-strip .hero-stat-label { font-size: clamp(0.48rem, 1.5vw, 0.62rem); letter-spacing: 0.04em; }@@@ -15034,11 +15034,16 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg}.hero-stat:hover { border-color: color-mix(in srgb, var(--accent) 38%, transparent); }.hero-stat--clickable:hover { transform: translateY(-1px); }++/* The tile is sized off the chip, and the glyph is deliberately a little under half of it — a++ glyph that fills its tile reads as cramped next to a nine-point label. Circular, because a++ 26px rounded square is neither one thing nor the other. */.hero-stat > i {-- width: clamp(24px, 3.4vw, 30px); height: clamp(24px, 3.4vw, 30px); flex: none;-- border-radius: 9px; display: grid; place-items: center;-- background: color-mix(in srgb, var(--accent) 14%, transparent);-- border: 1px solid color-mix(in srgb, var(--accent) 26%, transparent);++ width: clamp(26px, 6.6vw, 34px); height: clamp(26px, 6.6vw, 34px); flex: none;++ border-radius: 50%;++ font-size: clamp(0.62rem, 2.6vw, 0.82rem);++ background: radial-gradient(120% 120% at 30% 20%, color-mix(in srgb, var(--accent) 24%, transparent), color-mix(in srgb, var(--accent) 11%, transparent));++ border: 1px solid color-mix(in srgb, var(--accent) 28%, transparent);++ box-shadow: inset 0 1px 0 rgba(255,255,255,0.07);}.hero-stat--live > i { color: var(--live-red); background: color-mix(in srgb, var(--live-red) 15%, transparent); border-color: color-mix(in srgb, var(--live-red) 34%, transparent); }.hero-stat-numline { display: flex; align-items: baseline; gap: 6px; flex-wrap: wrap; min-width: 0; }@@@ -15099,7 +15104,7 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srggrid-template-columns: auto minmax(0, 1fr);align-content: center; gap: 3px clamp(6px, 1vw, 9px);}--.hero-stat > i { grid-row: 1 / 2; align-self: center; }++.hero-stat > i { grid-row: 1 / 2; align-self: center; justify-self: center; }.hero-stat-meta { grid-column: 2; min-width: 0; display: flex; flex-direction: column; gap: 1px; }.hero-stat-num { white-space: nowrap; line-height: 1.12; }/* Never truncate a label — an unreadable "US…" is worse than a second line. Wrapping is the@@@ -88,7 +88,7 @@overlay) and is fetched without blocking the first paint: media="print" makes the browsertreat it as non-critical, and the onload flips it back to all once it has arrived. --><link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>-- <link rel="stylesheet" href="/css/style.css?v=187">++ <link rel="stylesheet" href="/css/style.css?v=189"><link rel="stylesheet" href="/css/home-fx.css?v=12"><link rel="stylesheet" href="/css/home-tour.css?v=7"><link rel="stylesheet" href="/css/i18n-star.css?v=5">@@@ -5130,7 +5130,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig<script src="/live-notify.js?v=3" defer></script><script src="/js/ov-skeleton.js?v=1" defer></script><script src="/js/ov-odometer.js?v=3" defer></script>--<script src="/js/app.js?v=160" defer></script>++<script src="/js/app.js?v=161" defer></script><script src="/js/stream-player.js?v=22" defer></script><script src="/js/stream-pip.js?v=1" defer></script><script src="/js/chat-shared.js?v=3" defer></script>@@@ -1325,6 +1325,23 @@ function startHeroQuips(quips) {}// ── Hero stats bar (animated count-up) ──────────────────────────++function _baselineDeltaHTML(now, avg, peak, window = '24h') {++ const n = Number(now) || 0;++ const a = Number(avg);++ const fmtAvg = (v) => (v < 10 ? String(Math.round(v * 10) / 10) : _fmtCount(Math.round(v)));++ if (!Number.isFinite(a) || a <= 0) {++ const tip = n > 0 ? `${_fmtCount(n)} right now — nothing recorded in the last ${window}`++ : `Nothing in the last ${window}`;++ return `<span class="hero-stat-delta ${n > 0 ? 'is-up' : 'is-flat'}" title="${esc(tip)}"><b>${n > 0 ? 'first today' : 'quiet'}</b></span>`;++ }++ const pct = Math.round(((n - a) / a) * 100);++ const bits = [`${_fmtCount(n)} right now`, `${fmtAvg(a)} on average over the last ${window}`];++ if (Number.isFinite(Number(peak))) bits.push(`peak ${_fmtCount(Math.round(peak))}`);++ const cls = pct > 0 ? 'is-up' : pct < 0 ? 'is-down' : 'is-flat';++ return `<span class="hero-stat-delta ${cls}" title="${esc(bits.join(' · '))}">`++ + `<b>${pct > 0 ? '+' : ''}${pct}%</b><em>vs ${window}</em></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';@@@ -1441,6 +1458,14 @@ function renderHeroStats(stats) {const up = pct > 0;return `<span class="hero-stat-trend ${up ? 'is-up' : 'is-down'}"><i></i>${Math.abs(pct)}%</span>`;};++ /**++ * The two instantaneous readings — streams live and people watching — against what's normal.++ *++ * A seven-day total makes no sense for a reading, but "busier than usual" does, and the++ * five-minute sampler has a week of history to compare against. Averages ignore samples where++ * nothing was happening, so an empty night doesn't make every afternoon look like a record.++ */++ const baselineDelta = (now, avg, peak, window = '24h') => _baselineDeltaHTML(now, avg, peak, window);const recDelta = (rec, u = '') => {if (!rec) return '';const w = Number(rec.w), pw = Number(rec.pw);@@@ -1463,7 +1488,7 @@ function renderHeroStats(stats) {metric: r.metric || null,};const clickable = !!r.metric;-- return `<div class="hero-stat ${r.cls || ''} ${clickable ? 'hero-stat--clickable' : ''}" ${r.key ? `data-stat="${r.key}"` : ''} data-tip="${esc(JSON.stringify(tip))}" ${clickable ? `data-metric="${r.metric}" role="button" tabindex="0" aria-label="${esc(r.label)} — show over time"` : ''}><i class="fa-solid ${r.icon}"></i><div class="hero-stat-meta"><span class="hero-stat-num" data-n="${r.num || 0}">0</span><span class="hero-stat-label">${r.label}${clickable ? ' <i class="fa-solid fa-chart-line hero-stat-chart-hint"></i>' : ''}</span>${sub && !r.recent ? `<span class="hero-stat-sub">${sub}</span>` : ''}</div>${recDelta(r.recent, r.unit || '')}</div>`;++ return `<div class="hero-stat ${r.cls || ''} ${clickable ? 'hero-stat--clickable' : ''}" ${r.key ? `data-stat="${r.key}"` : ''} data-tip="${esc(JSON.stringify(tip))}" ${clickable ? `data-metric="${r.metric}" role="button" tabindex="0" aria-label="${esc(r.label)} — show over time"` : ''}><i class="fa-solid ${r.icon}"></i><div class="hero-stat-meta"><span class="hero-stat-num" data-n="${r.num || 0}">0</span><span class="hero-stat-label">${r.label}${clickable ? ' <i class="fa-solid fa-chart-line hero-stat-chart-hint"></i>' : ''}</span>${sub && !r.recent ? `<span class="hero-stat-sub">${sub}</span>` : ''}</div>${r.deltaHTML || recDelta(r.recent, r.unit || '')}</div>`;};// The full board is four groups and two dozen chips. Shown by default it pushed every live// stream on the site below the fold, and because it only arrives once the stats request lands@@@ -1475,9 +1500,10 @@ function renderHeroStats(stats) {// Seven live numbers, three to a row. These are the ones that move — every other number on// the board is an all-time total that changes once an hour at best, so it lives under the// toggle. Each is keyed so the poller below can roll it to a new value without a re-render.++ const CC = stats.concurrency || {};const HEADLINE = [-- { key: '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' },-- { key: 'viewersNow', icon: 'fa-eye', cls: stats.viewersNow > 0 ? 'hero-stat--live' : '', num: stats.viewersNow, label: 'Watching', title: 'Viewers watching right now' },++ { key: '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: baselineDelta(stats.liveNow, CC.liveAvg24h, CC.livePeak24h) },++ { key: 'viewersNow', icon: 'fa-eye', cls: stats.viewersNow > 0 ? 'hero-stat--live' : '', num: stats.viewersNow, label: 'Watching', title: 'Viewers watching right now', deltaHTML: baselineDelta(stats.viewersNow, CC.viewersAvg24h, CC.viewersPeak24h) },{ key: 'weeklyActive', icon: 'fa-fire', num: stats.weeklyActive, label: 'Active · 7d', title: 'People who chatted in the last 7 days', recent: { w: stats.weeklyActive, pw: stats.prevWeeklyActive } },{ key: 'users', icon: 'fa-user-group', num: stats.users, label: 'Users', title: 'Accounts on OpenVibe.Live', recent: R.users },{ key: 'weeklyVisitors', icon: 'fa-user-plus', num: stats.weeklyVisitors, label: 'Visitors · 7d', title: 'First-time visitors in the last 7 days', recent: { w: stats.weeklyVisitors, pw: stats.prevWeeklyVisitors } },@@@ -1606,6 +1632,16 @@ function _startHeroStatsLive() {weeklyActive: { w: d.weeklyActive, pw: d.prevWeeklyActive },weeklyVisitors: { w: d.weeklyVisitors, pw: d.prevWeeklyVisitors },};++ // 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 || {};++ const reBase = [['liveNow', d.liveNow, cc.liveAvg24h, cc.livePeak24h], ['viewersNow', d.viewersNow, cc.viewersAvg24h, cc.viewersPeak24h]];++ for (const [key, now, avg, peak] of reBase) {++ wrap.querySelectorAll(`[data-stat="${key}"] .hero-stat-delta`).forEach(el => {++ const html = _baselineDeltaHTML(now, avg, peak);++ if (html) el.outerHTML = html;++ });++ }for (const [key, rec] of Object.entries(DELTA_FOR)) {const w = rec && Number(rec.w);if (!Number.isFinite(w)) continue;@@@ -3926,6 +3926,35 @@ function recordViewerSample() {run(`DELETE FROM viewer_samples WHERE sampled_at < datetime('now', '-7 days')`);return r;}++/**++ * What "normal" looks like for the two instantaneous numbers.++ *++ * Live streams and current viewers have no seven-day total — they are readings, not counters, so++ * "+2 in 7d" is meaningless for them. What is meaningful is whether right now is busier or++ * quieter than usual, and the five-minute sampler has been recording exactly that for a week.++ * Averages are taken over samples where anything was happening, so a quiet night does not drag++ * the baseline to zero and make every daytime reading look like a record.++ */++function getConcurrencyBaseline() {++ _ensureViewerSamples();++ const row = get(`++ SELECT++ AVG(CASE WHEN sampled_at >= datetime('now','-1 day') AND viewers > 0 THEN viewers END) AS vAvg24,++ MAX(CASE WHEN sampled_at >= datetime('now','-1 day') THEN viewers END) AS vPeak24,++ AVG(CASE WHEN viewers > 0 THEN viewers END) AS vAvg7,++ AVG(CASE WHEN sampled_at >= datetime('now','-1 day') AND live_streams > 0 THEN live_streams END) AS lAvg24,++ MAX(CASE WHEN sampled_at >= datetime('now','-1 day') THEN live_streams END) AS lPeak24,++ AVG(CASE WHEN live_streams > 0 THEN live_streams END) AS lAvg7,++ COUNT(*) AS samples++ FROM viewer_samples WHERE sampled_at >= datetime('now','-7 days')`) || {};++ const num = (v) => (Number.isFinite(Number(v)) ? Math.round(Number(v) * 10) / 10 : null);++ return {++ viewersAvg24h: num(row.vAvg24), viewersPeak24h: num(row.vPeak24), viewersAvg7d: num(row.vAvg7),++ liveAvg24h: num(row.lAvg24), livePeak24h: num(row.lPeak24), liveAvg7d: num(row.lAvg7),++ samples: Number(row.samples) || 0,++ };++}++function getViewerTrend(hours = 24, maxPoints = 48) {_ensureViewerSamples();const rows = all(`SELECT strftime('%s', sampled_at) AS t, viewers, live_streams FROM viewer_samples@@@ -8196,6 +8225,7 @@ function addToDonationGoal(id, amount) {}module.exports = {++ getConcurrencyBaseline,getHomeStatSeries, HOME_SERIES_KEYS, vibesStatsSince, _computeHomeStats,getVodAiState, getClipAiState,scheduleClipNotifyState, bumpClipNotifyNowState, markClipNotifiedState, getDueClipNotifies,@@@ -202,6 +202,7 @@ async function heroStats() {stats.coinHolders = c.holders;}} catch { /* the board is better off missing four chips than failing */ }++ try { stats.concurrency = db.getConcurrencyBaseline(); } catch { /* */ }return stats;}@@@ -223,6 +224,9 @@ router.get('/stats-live', (req, res) => {// 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,++ // What counts as a normal number of live streams and viewers, so the two instantaneous++ // readings can say whether right now is busy or quiet.++ concurrency: (() => { try { return db.getConcurrencyBaseline(); } catch { return null; } })(),recent: {users: s.recent?.users, anons: s.recent?.anons,messages: s.recent?.messages,
@@ -2342,7 +2342,7 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
@@ -2342,7 +2342,7 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
border: 1px solid color-mix(in srgb, var(--accent) 24%, transparent); backdrop-filter: blur(8px);
border: 1px solid color-mix(in srgb, var(--accent) 24%, transparent); backdrop-filter: blur(8px);
box-shadow: 0 6px 20px -12px rgba(0, 0, 0, 0.7);
box-shadow: 0 6px 20px -12px rgba(0, 0, 0, 0.7);
}
}
-.hero-stat > i { color: var(--accent); font-size: 1.05rem; }
+.hero-stat > i { color: var(--accent); }
.hero-stat-meta { display: flex; flex-direction: column; align-items: flex-start; line-height: 1.05; min-width: 0; }
.hero-stat-meta { display: flex; flex-direction: column; align-items: flex-start; line-height: 1.05; min-width: 0; }
.hero-stat-label, .hero-stat-sub { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
.hero-stat-label, .hero-stat-sub { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
.hero-stat-num { font-size: 1.22rem; font-weight: 800; color: var(--text-primary); font-variant-numeric: tabular-nums; }
.hero-stat-num { font-size: 1.22rem; font-weight: 800; color: var(--text-primary); font-variant-numeric: tabular-nums; }
@@ -2367,7 +2367,8 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
@@ -2367,7 +2367,8 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
.hero-stat-kicker { padding-left: 10px; border-left-width: 2px; }
.hero-stat-kicker { padding-left: 10px; border-left-width: 2px; }
.hero-stat-row { grid-template-columns: repeat(auto-fit, minmax(104px, 1fr)); gap: 6px; margin-left: 10px; }
.hero-stat-row { grid-template-columns: repeat(auto-fit, minmax(104px, 1fr)); gap: 6px; margin-left: 10px; }
.hero-stat { padding: 7px 9px; gap: 7px; }
.hero-stat { padding: 7px 9px; gap: 7px; }
- .hero-stat > i { display: none; } /* icons cost the room labels need at 3-up */
+ /* (The old rule here hid the icon at 3-up to make room for labels. The tile design sizes
+ itself off the chip now and the labels are short words, so both fit.) */
.hero-stat-label { white-space: normal; line-height: 1.1; }
.hero-stat-label { white-space: normal; line-height: 1.1; }
.hero-stat-num { font-size: 1.05rem; }
.hero-stat-num { font-size: 1.05rem; }
.hero-float { border-radius: 10px; }
.hero-float { border-radius: 10px; }
@@ -14103,8 +14104,8 @@ label.staff-inline-toggle.aiv-field { flex-direction:row; align-items:center; }
@@ -14103,8 +14104,8 @@ label.staff-inline-toggle.aiv-field { flex-direction:row; align-items:center; }
.hero-chart-foot b { color: var(--text-primary); }
.hero-chart-foot b { color: var(--text-primary); }
.hero-chart-loading { padding: 60px 0; text-align: center; color: var(--text-muted); }
.hero-chart-loading { padding: 60px 0; text-align: center; color: var(--text-muted); }
@media (max-width: 720px) {
@media (max-width: 720px) {
- /* Icons back on phones — smaller, and the label wraps instead of hiding them. */
- .hero-stat > i { display: inline-block !important; font-size: 0.85rem; }
+ /* (The counterpart !important rule lived here. It forced display:inline-block, which beat the
+ tile's grid centring and left every glyph sitting off-centre.) */
.hero-stat { padding: 7px 8px; gap: 6px; }
.hero-stat { padding: 7px 8px; gap: 6px; }
.hero-stat-label { white-space: normal; line-height: 1.15; }
.hero-stat-label { white-space: normal; line-height: 1.15; }
.hero-stat-chart-hint { opacity: 0.6; }
.hero-stat-chart-hint { opacity: 0.6; }
@@ -14818,7 +14819,6 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
@@ -14818,7 +14819,6 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
max-width: 940px; margin: 0 auto; width: 100%;
max-width: 940px; margin: 0 auto; width: 100%;
}
}
.hero-stat-strip .hero-stat { min-width: 0; padding: clamp(6px, 1.1vw, 10px) clamp(7px, 1.2vw, 12px); gap: clamp(5px, 0.9vw, 10px); }
.hero-stat-strip .hero-stat { min-width: 0; padding: clamp(6px, 1.1vw, 10px) clamp(7px, 1.2vw, 12px); gap: clamp(5px, 0.9vw, 10px); }
-.hero-stat-strip .hero-stat > i { font-size: clamp(0.72rem, 1.6vw, 1.05rem); }
/* Sized against the chip, not the viewport: at three per row a chip is roughly a third of the
/* Sized against the chip, not the viewport: at three per row a chip is roughly a third of the
width minus gaps, so 3.2vw tracks the space actually available and clamps at both ends. */
width minus gaps, so 3.2vw tracks the space actually available and clamps at both ends. */
.hero-stat-strip .hero-stat-label { font-size: clamp(0.48rem, 1.5vw, 0.62rem); letter-spacing: 0.04em; }
.hero-stat-strip .hero-stat-label { font-size: clamp(0.48rem, 1.5vw, 0.62rem); letter-spacing: 0.04em; }
@@ -15034,11 +15034,16 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
@@ -15034,11 +15034,16 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
}
}
.hero-stat:hover { border-color: color-mix(in srgb, var(--accent) 38%, transparent); }
.hero-stat:hover { border-color: color-mix(in srgb, var(--accent) 38%, transparent); }
.hero-stat--clickable:hover { transform: translateY(-1px); }
.hero-stat--clickable:hover { transform: translateY(-1px); }
+/* The tile is sized off the chip, and the glyph is deliberately a little under half of it — a
+ glyph that fills its tile reads as cramped next to a nine-point label. Circular, because a
+ 26px rounded square is neither one thing nor the other. */
.hero-stat > i {
.hero-stat > i {
- width: clamp(24px, 3.4vw, 30px); height: clamp(24px, 3.4vw, 30px); flex: none;
- border-radius: 9px; display: grid; place-items: center;
- background: color-mix(in srgb, var(--accent) 14%, transparent);
- border: 1px solid color-mix(in srgb, var(--accent) 26%, transparent);
+ width: clamp(26px, 6.6vw, 34px); height: clamp(26px, 6.6vw, 34px); flex: none;
+ border-radius: 50%;
+ font-size: clamp(0.62rem, 2.6vw, 0.82rem);
+ background: radial-gradient(120% 120% at 30% 20%, color-mix(in srgb, var(--accent) 24%, transparent), color-mix(in srgb, var(--accent) 11%, transparent));
+ border: 1px solid color-mix(in srgb, var(--accent) 28%, transparent);
+ box-shadow: inset 0 1px 0 rgba(255,255,255,0.07);
}
}
.hero-stat--live > i { color: var(--live-red); background: color-mix(in srgb, var(--live-red) 15%, transparent); border-color: color-mix(in srgb, var(--live-red) 34%, transparent); }
.hero-stat--live > i { color: var(--live-red); background: color-mix(in srgb, var(--live-red) 15%, transparent); border-color: color-mix(in srgb, var(--live-red) 34%, transparent); }
.hero-stat-numline { display: flex; align-items: baseline; gap: 6px; flex-wrap: wrap; min-width: 0; }
.hero-stat-numline { display: flex; align-items: baseline; gap: 6px; flex-wrap: wrap; min-width: 0; }
@@ -15099,7 +15104,7 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
@@ -15099,7 +15104,7 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
grid-template-columns: auto minmax(0, 1fr);
grid-template-columns: auto minmax(0, 1fr);
align-content: center; gap: 3px clamp(6px, 1vw, 9px);
align-content: center; gap: 3px clamp(6px, 1vw, 9px);
}
}
-.hero-stat > i { grid-row: 1 / 2; align-self: center; }
+.hero-stat > i { grid-row: 1 / 2; align-self: center; justify-self: center; }
.hero-stat-meta { grid-column: 2; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
.hero-stat-meta { grid-column: 2; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
.hero-stat-num { white-space: nowrap; line-height: 1.12; }
.hero-stat-num { white-space: nowrap; line-height: 1.12; }
/* Never truncate a label — an unreadable "US…" is worse than a second line. Wrapping is the
/* Never truncate a label — an unreadable "US…" is worse than a second line. Wrapping is the
@@ -88,7 +88,7 @@
@@ -88,7 +88,7 @@
overlay) and is fetched without blocking the first paint: media="print" makes the browser
overlay) and is fetched without blocking the first paint: media="print" makes the browser
treat it as non-critical, and the onload flips it back to all once it has arrived. -->
treat it as non-critical, and the onload flips it back to all once it has arrived. -->
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
- <link rel="stylesheet" href="/css/style.css?v=187">
+ <link rel="stylesheet" href="/css/style.css?v=189">
<link rel="stylesheet" href="/css/home-fx.css?v=12">
<link rel="stylesheet" href="/css/home-fx.css?v=12">
<link rel="stylesheet" href="/css/home-tour.css?v=7">
<link rel="stylesheet" href="/css/home-tour.css?v=7">
<link rel="stylesheet" href="/css/i18n-star.css?v=5">
<link rel="stylesheet" href="/css/i18n-star.css?v=5">
@@ -5130,7 +5130,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
@@ -5130,7 +5130,7 @@ curl -s https://openvibe.media/live/@Goosely/chat-insight.json | jq ".chat_insig
<script src="/live-notify.js?v=3" defer></script>
<script src="/live-notify.js?v=3" defer></script>
<script src="/js/ov-skeleton.js?v=1" defer></script>
<script src="/js/ov-skeleton.js?v=1" defer></script>
<script src="/js/ov-odometer.js?v=3" defer></script>
<script src="/js/ov-odometer.js?v=3" defer></script>
-<script src="/js/app.js?v=160" defer></script>
+<script src="/js/app.js?v=161" defer></script>
<script src="/js/stream-player.js?v=22" defer></script>
<script src="/js/stream-player.js?v=22" defer></script>
<script src="/js/stream-pip.js?v=1" defer></script>
<script src="/js/stream-pip.js?v=1" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
<script src="/js/chat-shared.js?v=3" defer></script>
@@ -1325,6 +1325,23 @@ function startHeroQuips(quips) {
@@ -1325,6 +1325,23 @@ function startHeroQuips(quips) {
}
}
// ── Hero stats bar (animated count-up) ──────────────────────────
// ── Hero stats bar (animated count-up) ──────────────────────────
+function _baselineDeltaHTML(now, avg, peak, window = '24h') {
+ const n = Number(now) || 0;
+ const a = Number(avg);
+ const fmtAvg = (v) => (v < 10 ? String(Math.round(v * 10) / 10) : _fmtCount(Math.round(v)));
+ if (!Number.isFinite(a) || a <= 0) {
+ const tip = n > 0 ? `${_fmtCount(n)} right now — nothing recorded in the last ${window}`
+ : `Nothing in the last ${window}`;
+ return `<span class="hero-stat-delta ${n > 0 ? 'is-up' : 'is-flat'}" title="${esc(tip)}"><b>${n > 0 ? 'first today' : 'quiet'}</b></span>`;
+ }
+ const pct = Math.round(((n - a) / a) * 100);
+ const bits = [`${_fmtCount(n)} right now`, `${fmtAvg(a)} on average over the last ${window}`];
+ if (Number.isFinite(Number(peak))) bits.push(`peak ${_fmtCount(Math.round(peak))}`);
+ const cls = pct > 0 ? 'is-up' : pct < 0 ? 'is-down' : 'is-flat';
+ return `<span class="hero-stat-delta ${cls}" title="${esc(bits.join(' · '))}">`
+ + `<b>${pct > 0 ? '+' : ''}${pct}%</b><em>vs ${window}</em></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';
@@ -1441,6 +1458,14 @@ function renderHeroStats(stats) {
@@ -1441,6 +1458,14 @@ function renderHeroStats(stats) {
const up = pct > 0;
const up = pct > 0;
return `<span class="hero-stat-trend ${up ? 'is-up' : 'is-down'}"><i></i>${Math.abs(pct)}%</span>`;
return `<span class="hero-stat-trend ${up ? 'is-up' : 'is-down'}"><i></i>${Math.abs(pct)}%</span>`;
};
};
+ /**
+ * The two instantaneous readings — streams live and people watching — against what's normal.
+ *
+ * A seven-day total makes no sense for a reading, but "busier than usual" does, and the
+ * five-minute sampler has a week of history to compare against. Averages ignore samples where
+ * nothing was happening, so an empty night doesn't make every afternoon look like a record.
+ */
+ const baselineDelta = (now, avg, peak, window = '24h') => _baselineDeltaHTML(now, avg, peak, window);
const recDelta = (rec, u = '') => {
const recDelta = (rec, u = '') => {
if (!rec) return '';
if (!rec) return '';
const w = Number(rec.w), pw = Number(rec.pw);
const w = Number(rec.w), pw = Number(rec.pw);
@@ -1463,7 +1488,7 @@ function renderHeroStats(stats) {
@@ -1463,7 +1488,7 @@ function renderHeroStats(stats) {
metric: r.metric || null,
metric: r.metric || null,
};
};
const clickable = !!r.metric;
const clickable = !!r.metric;
- return `<div class="hero-stat ${r.cls || ''} ${clickable ? 'hero-stat--clickable' : ''}" ${r.key ? `data-stat="${r.key}"` : ''} data-tip="${esc(JSON.stringify(tip))}" ${clickable ? `data-metric="${r.metric}" role="button" tabindex="0" aria-label="${esc(r.label)} — show over time"` : ''}><i class="fa-solid ${r.icon}"></i><div class="hero-stat-meta"><span class="hero-stat-num" data-n="${r.num || 0}">0</span><span class="hero-stat-label">${r.label}${clickable ? ' <i class="fa-solid fa-chart-line hero-stat-chart-hint"></i>' : ''}</span>${sub && !r.recent ? `<span class="hero-stat-sub">${sub}</span>` : ''}</div>${recDelta(r.recent, r.unit || '')}</div>`;
+ return `<div class="hero-stat ${r.cls || ''} ${clickable ? 'hero-stat--clickable' : ''}" ${r.key ? `data-stat="${r.key}"` : ''} data-tip="${esc(JSON.stringify(tip))}" ${clickable ? `data-metric="${r.metric}" role="button" tabindex="0" aria-label="${esc(r.label)} — show over time"` : ''}><i class="fa-solid ${r.icon}"></i><div class="hero-stat-meta"><span class="hero-stat-num" data-n="${r.num || 0}">0</span><span class="hero-stat-label">${r.label}${clickable ? ' <i class="fa-solid fa-chart-line hero-stat-chart-hint"></i>' : ''}</span>${sub && !r.recent ? `<span class="hero-stat-sub">${sub}</span>` : ''}</div>${r.deltaHTML || recDelta(r.recent, r.unit || '')}</div>`;
};
};
// The full board is four groups and two dozen chips. Shown by default it pushed every live
// The full board is four groups and two dozen chips. Shown by default it pushed every live
// stream on the site below the fold, and because it only arrives once the stats request lands
// stream on the site below the fold, and because it only arrives once the stats request lands
@@ -1475,9 +1500,10 @@ function renderHeroStats(stats) {
@@ -1475,9 +1500,10 @@ function renderHeroStats(stats) {
// Seven live numbers, three to a row. These are the ones that move — every other number on
// Seven live numbers, three to a row. These are the ones that move — every other number on
// the board is an all-time total that changes once an hour at best, so it lives under the
// the board is an all-time total that changes once an hour at best, so it lives under the
// toggle. Each is keyed so the poller below can roll it to a new value without a re-render.
// toggle. Each is keyed so the poller below can roll it to a new value without a re-render.
+ const CC = stats.concurrency || {};
const HEADLINE = [
const HEADLINE = [
- { key: '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' },
- { key: 'viewersNow', icon: 'fa-eye', cls: stats.viewersNow > 0 ? 'hero-stat--live' : '', num: stats.viewersNow, label: 'Watching', title: 'Viewers watching right now' },
+ { key: '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: baselineDelta(stats.liveNow, CC.liveAvg24h, CC.livePeak24h) },
+ { key: 'viewersNow', icon: 'fa-eye', cls: stats.viewersNow > 0 ? 'hero-stat--live' : '', num: stats.viewersNow, label: 'Watching', title: 'Viewers watching right now', deltaHTML: baselineDelta(stats.viewersNow, CC.viewersAvg24h, CC.viewersPeak24h) },
{ key: 'weeklyActive', icon: 'fa-fire', num: stats.weeklyActive, label: 'Active · 7d', title: 'People who chatted in the last 7 days', recent: { w: stats.weeklyActive, pw: stats.prevWeeklyActive } },
{ key: 'weeklyActive', icon: 'fa-fire', num: stats.weeklyActive, label: 'Active · 7d', title: 'People who chatted in the last 7 days', recent: { w: stats.weeklyActive, pw: stats.prevWeeklyActive } },
{ key: 'users', icon: 'fa-user-group', num: stats.users, label: 'Users', title: 'Accounts on OpenVibe.Live', recent: R.users },
{ key: 'users', icon: 'fa-user-group', num: stats.users, label: 'Users', title: 'Accounts on OpenVibe.Live', recent: R.users },
{ key: 'weeklyVisitors', icon: 'fa-user-plus', num: stats.weeklyVisitors, label: 'Visitors · 7d', title: 'First-time visitors in the last 7 days', recent: { w: stats.weeklyVisitors, pw: stats.prevWeeklyVisitors } },
{ key: 'weeklyVisitors', icon: 'fa-user-plus', num: stats.weeklyVisitors, label: 'Visitors · 7d', title: 'First-time visitors in the last 7 days', recent: { w: stats.weeklyVisitors, pw: stats.prevWeeklyVisitors } },
@@ -1606,6 +1632,16 @@ function _startHeroStatsLive() {
@@ -1606,6 +1632,16 @@ function _startHeroStatsLive() {
weeklyActive: { w: d.weeklyActive, pw: d.prevWeeklyActive },
weeklyActive: { w: d.weeklyActive, pw: d.prevWeeklyActive },
weeklyVisitors: { w: d.weeklyVisitors, pw: d.prevWeeklyVisitors },
weeklyVisitors: { w: d.weeklyVisitors, pw: d.prevWeeklyVisitors },
};
};
+ // 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 || {};
+ const reBase = [['liveNow', d.liveNow, cc.liveAvg24h, cc.livePeak24h], ['viewersNow', d.viewersNow, cc.viewersAvg24h, cc.viewersPeak24h]];
+ for (const [key, now, avg, peak] of reBase) {
+ wrap.querySelectorAll(`[data-stat="${key}"] .hero-stat-delta`).forEach(el => {
+ const html = _baselineDeltaHTML(now, avg, peak);
+ if (html) el.outerHTML = html;
+ });
+ }
for (const [key, rec] of Object.entries(DELTA_FOR)) {
for (const [key, rec] of Object.entries(DELTA_FOR)) {
const w = rec && Number(rec.w);
const w = rec && Number(rec.w);
if (!Number.isFinite(w)) continue;
if (!Number.isFinite(w)) continue;
@@ -3926,6 +3926,35 @@ function recordViewerSample() {
@@ -3926,6 +3926,35 @@ function recordViewerSample() {
run(`DELETE FROM viewer_samples WHERE sampled_at < datetime('now', '-7 days')`);
run(`DELETE FROM viewer_samples WHERE sampled_at < datetime('now', '-7 days')`);
return r;
return r;
}
}
+/**
+ * What "normal" looks like for the two instantaneous numbers.
+ *
+ * Live streams and current viewers have no seven-day total — they are readings, not counters, so
+ * "+2 in 7d" is meaningless for them. What is meaningful is whether right now is busier or
+ * quieter than usual, and the five-minute sampler has been recording exactly that for a week.
+ * Averages are taken over samples where anything was happening, so a quiet night does not drag
+ * the baseline to zero and make every daytime reading look like a record.
+ */
+function getConcurrencyBaseline() {
+ _ensureViewerSamples();
+ const row = get(`
+ SELECT
+ AVG(CASE WHEN sampled_at >= datetime('now','-1 day') AND viewers > 0 THEN viewers END) AS vAvg24,
+ MAX(CASE WHEN sampled_at >= datetime('now','-1 day') THEN viewers END) AS vPeak24,
+ AVG(CASE WHEN viewers > 0 THEN viewers END) AS vAvg7,
+ AVG(CASE WHEN sampled_at >= datetime('now','-1 day') AND live_streams > 0 THEN live_streams END) AS lAvg24,
+ MAX(CASE WHEN sampled_at >= datetime('now','-1 day') THEN live_streams END) AS lPeak24,
+ AVG(CASE WHEN live_streams > 0 THEN live_streams END) AS lAvg7,
+ COUNT(*) AS samples
+ FROM viewer_samples WHERE sampled_at >= datetime('now','-7 days')`) || {};
+ const num = (v) => (Number.isFinite(Number(v)) ? Math.round(Number(v) * 10) / 10 : null);
+ return {
+ viewersAvg24h: num(row.vAvg24), viewersPeak24h: num(row.vPeak24), viewersAvg7d: num(row.vAvg7),
+ liveAvg24h: num(row.lAvg24), livePeak24h: num(row.lPeak24), liveAvg7d: num(row.lAvg7),
+ samples: Number(row.samples) || 0,
+ };
+}
+
function getViewerTrend(hours = 24, maxPoints = 48) {
function getViewerTrend(hours = 24, maxPoints = 48) {
_ensureViewerSamples();
_ensureViewerSamples();
const rows = all(`SELECT strftime('%s', sampled_at) AS t, viewers, live_streams FROM viewer_samples
const rows = all(`SELECT strftime('%s', sampled_at) AS t, viewers, live_streams FROM viewer_samples
@@ -8196,6 +8225,7 @@ function addToDonationGoal(id, amount) {
@@ -8196,6 +8225,7 @@ function addToDonationGoal(id, amount) {
}
}
module.exports = {
module.exports = {
+ getConcurrencyBaseline,
getHomeStatSeries, HOME_SERIES_KEYS, vibesStatsSince, _computeHomeStats,
getHomeStatSeries, HOME_SERIES_KEYS, vibesStatsSince, _computeHomeStats,
getVodAiState, getClipAiState,
getVodAiState, getClipAiState,
scheduleClipNotifyState, bumpClipNotifyNowState, markClipNotifiedState, getDueClipNotifies,
scheduleClipNotifyState, bumpClipNotifyNowState, markClipNotifiedState, getDueClipNotifies,
@@ -202,6 +202,7 @@ async function heroStats() {
@@ -202,6 +202,7 @@ async function heroStats() {
stats.coinHolders = c.holders;
stats.coinHolders = c.holders;
}
}
} catch { /* the board is better off missing four chips than failing */ }
} catch { /* the board is better off missing four chips than failing */ }
+ try { stats.concurrency = db.getConcurrencyBaseline(); } catch { /* */ }
return stats;
return stats;
}
}
@@ -223,6 +224,9 @@ router.get('/stats-live', (req, res) => {
@@ -223,6 +224,9 @@ router.get('/stats-live', (req, res) => {
// 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,
+ // What counts as a normal number of live streams and viewers, so the two instantaneous
+ // readings can say whether right now is busy or quiet.
+ concurrency: (() => { try { return db.getConcurrencyBaseline(); } catch { return null; } })(),
recent: {
recent: {
users: s.recent?.users, anons: s.recent?.anons,
users: s.recent?.users, anons: s.recent?.anons,
messages: s.recent?.messages,
messages: s.recent?.messages,