openvibers/openvibe.live Commits
Hero chips: 7-day deltas, direction, and a design that says what matters
Changes
@@@ -15010,3 +15010,44 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg.home-load-more:hover i { transform: translateY(2px); }.home-load-more small { font-size: 0.72rem; font-weight: 600; color: var(--text-muted); }@media (prefers-reduced-motion: reduce) { .home-load-more, .home-load-more i { transition: none; } }++++/* ── Hero stat chips ─────────────────────────────────────────────────────────++ The chips were an icon, a number and a grey label — three flat things in a row with nothing++ telling you which part mattered. Now the number leads, the 7-day change sits beside it as a++ tinted pill with a direction arrow (movement reads as movement; "+2 in 7d" in grey read as a++ footnote), and the label sits underneath in small caps. The icon gets a tile so it reads as a++ category marker rather than decoration. */++.hero-stat {++ position: relative; overflow: hidden;++ border-radius: 13px;++ background: linear-gradient(165deg, color-mix(in srgb, var(--accent) 9%, transparent), transparent 70%),++ color-mix(in srgb, var(--bg-card) 72%, transparent);++ border: 1px solid color-mix(in srgb, var(--accent) 17%, var(--border));++ transition: transform 0.18s cubic-bezier(.2, 1, .3, 1), border-color 0.2s, background 0.2s;++}++.hero-stat:hover { border-color: color-mix(in srgb, var(--accent) 38%, transparent); }++.hero-stat--clickable:hover { transform: translateY(-1px); }++.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);++}++.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-num { font-weight: 850; letter-spacing: -0.02em; }++.hero-stat-delta {++ display: inline-flex; align-items: center; gap: 2px;++ padding: 1px 6px 1px 4px; border-radius: 999px;++ font-size: 0.6rem; font-weight: 800; letter-spacing: 0.01em; white-space: nowrap;++ align-self: center;++}++.hero-stat-delta.is-up { color: #4ade80; background: color-mix(in srgb, #4ade80 14%, transparent); border: 1px solid color-mix(in srgb, #4ade80 26%, transparent); }++.hero-stat-delta.is-down { color: #f87171; background: color-mix(in srgb, #f87171 14%, transparent); border: 1px solid color-mix(in srgb, #f87171 26%, transparent); }++.hero-stat-delta i { font-size: 0.62rem; }++.hero-stat-delta em { font-style: normal; opacity: 0.62; margin-left: 2px; font-size: 0.92em; }++.hero-stat-label { text-transform: uppercase; font-weight: 700; color: var(--text-muted); }++@media (max-width: 420px) {++ .hero-stat-delta { font-size: 0.55rem; padding: 1px 5px 1px 3px; }++ .hero-stat-delta em { display: none; }++}@@@ -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=177">++ <link rel="stylesheet" href="/css/style.css?v=178"><link rel="stylesheet" href="/css/home-fx.css?v=11"><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=152" defer></script>++<script src="/js/app.js?v=153" 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>@@@ -1422,6 +1422,21 @@ function renderHeroStats(stats) {// custom tooltip (data-tip, see _heroTooltip) spells out 24h / 7d / 30d and what the// number means. Every chip with a series behind it is clickable → over-time chart.const recSub = (rec, u = '') => (rec && rec.w > 0) ? `+${_fmtCount(rec.w)}${u} in 7d` : '';++ /**++ * The 7-day change, as a direction rather than a sentence.++ *++ * "+2 in 7d" in muted grey reads as a footnote; an arrow and a tinted pill reads as movement,++ * which is the whole reason the number is interesting. Counters here only ever go up, but the++ * down case is handled so this works anywhere.++ */++ const recDelta = (rec, u = '') => {++ const w = rec && Number(rec.w);++ if (!Number.isFinite(w) || w === 0) return '';++ const up = w > 0;++ return `<span class="hero-stat-delta ${up ? 'is-up' : 'is-down'}" title="Change over the last 7 days">`++ + `<i class="fa-solid ${up ? 'fa-caret-up' : 'fa-caret-down'}"></i>`++ + `${up ? '+' : '-'}${_fmtCount(Math.abs(w))}${u}<em>7d</em></span>`;++ };const chip = (r) => {if (r.html) return r.html; // pre-rendered chips (sparkline)const sub = r.sub || recSub(r.recent, r.unit || '');@@@ -1431,7 +1446,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 ? `<span class="hero-stat-sub">${sub}</span>` : ''}</div></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-numline"><span class="hero-stat-num" data-n="${r.num || 0}">0</span>${recDelta(r.recent, r.unit || '')}</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></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@@@ -1447,12 +1462,12 @@ function renderHeroStats(stats) {{ 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: 'weeklyActive', icon: 'fa-fire', num: stats.weeklyActive, label: 'Active · 7d', title: 'People who chatted in the last 7 days' },-- { key: 'users', icon: 'fa-user-group', num: stats.users, label: 'Users', title: 'Accounts on OpenVibe.Live' },++ { 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' },-- { key: 'anons', icon: 'fa-user-secret', num: stats.anons, label: 'Anons', title: 'Anonymous chatters who have been given a name' },-- { key: 'chatMessages', icon: 'fa-comments', num: stats.chatMessages, label: 'Messages', title: 'Chat messages sent, all time' },++ { key: 'anons', icon: 'fa-user-secret', num: stats.anons, label: 'Anons', title: 'Anonymous chatters who have been given a name', recent: R.anons },++ { key: 'chatMessages', icon: 'fa-comments', num: stats.chatMessages, label: 'Messages', title: 'Chat messages sent, all time', recent: R.messages },{ key: 'streamers', icon: 'fa-satellite-dish', num: stats.streamers, label: 'Streamers', title: 'People who have gone live here' },-- { key: 'hoursWatched', icon: 'fa-couch', num: stats.hoursWatched, label: 'Hours watched', title: 'Hours the community has spent watching' },++ { key: 'hoursWatched', icon: 'fa-couch', num: stats.hoursWatched, label: 'Hours watched', title: 'Hours the community has spent watching', recent: R.hours },];const open = (() => { try { return localStorage.getItem('ov_stats_open') === '1'; } catch { return false; } })();wrap.innerHTML = `@@@ -1519,7 +1534,21 @@ function _startHeroStatsLive() {try {const d = await api('/home/stats-live');_heroStatsBackoff = 0;++ // Deltas move too — refresh them in place rather than rebuilding the chip.++ const DELTA_FOR = { users: 'users', anons: 'anons', chatMessages: 'messages', hoursWatched: 'hours' };++ for (const [key, field] of Object.entries(DELTA_FOR)) {++ const w = d && d.recent && d.recent[field] && Number(d.recent[field].w);++ wrap.querySelectorAll(`[data-stat="${key}"] .hero-stat-delta`).forEach(el => {++ if (!Number.isFinite(w) || w === 0) return;++ const txt = `${w > 0 ? '+' : '-'}${_fmtCount(Math.abs(w))}`;++ const cur = el.childNodes[1];++ if (cur && cur.nodeValue !== txt) cur.nodeValue = txt;++ el.classList.toggle('is-up', w > 0);++ el.classList.toggle('is-down', w < 0);++ });++ }for (const [key, value] of Object.entries(d || {})) {++ if (key === 'recent') continue;wrap.querySelectorAll(`[data-stat="${key}"] .hero-stat-num`).forEach(el => {if (Number(el.dataset.n) === Number(value)) return;if (window.OVNum) OVNum.set(el, value); else el.textContent = _heroStatFmt(value);@@@ -220,6 +220,12 @@ router.get('/stats-live', (req, res) => {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,++ // 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.++ recent: {++ users: s.recent?.users, anons: s.recent?.anons,++ messages: s.recent?.messages, hours: s.recent?.hours,++ },};_liveStats = { at: Date.now(), data };res.json(data);
@@ -15010,3 +15010,44 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
@@ -15010,3 +15010,44 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
.home-load-more:hover i { transform: translateY(2px); }
.home-load-more:hover i { transform: translateY(2px); }
.home-load-more small { font-size: 0.72rem; font-weight: 600; color: var(--text-muted); }
.home-load-more small { font-size: 0.72rem; font-weight: 600; color: var(--text-muted); }
@media (prefers-reduced-motion: reduce) { .home-load-more, .home-load-more i { transition: none; } }
@media (prefers-reduced-motion: reduce) { .home-load-more, .home-load-more i { transition: none; } }
+
+/* ── Hero stat chips ─────────────────────────────────────────────────────────
+ The chips were an icon, a number and a grey label — three flat things in a row with nothing
+ telling you which part mattered. Now the number leads, the 7-day change sits beside it as a
+ tinted pill with a direction arrow (movement reads as movement; "+2 in 7d" in grey read as a
+ footnote), and the label sits underneath in small caps. The icon gets a tile so it reads as a
+ category marker rather than decoration. */
+.hero-stat {
+ position: relative; overflow: hidden;
+ border-radius: 13px;
+ background: linear-gradient(165deg, color-mix(in srgb, var(--accent) 9%, transparent), transparent 70%),
+ color-mix(in srgb, var(--bg-card) 72%, transparent);
+ border: 1px solid color-mix(in srgb, var(--accent) 17%, var(--border));
+ transition: transform 0.18s cubic-bezier(.2, 1, .3, 1), border-color 0.2s, background 0.2s;
+}
+.hero-stat:hover { border-color: color-mix(in srgb, var(--accent) 38%, transparent); }
+.hero-stat--clickable:hover { transform: translateY(-1px); }
+.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);
+}
+.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-num { font-weight: 850; letter-spacing: -0.02em; }
+.hero-stat-delta {
+ display: inline-flex; align-items: center; gap: 2px;
+ padding: 1px 6px 1px 4px; border-radius: 999px;
+ font-size: 0.6rem; font-weight: 800; letter-spacing: 0.01em; white-space: nowrap;
+ align-self: center;
+}
+.hero-stat-delta.is-up { color: #4ade80; background: color-mix(in srgb, #4ade80 14%, transparent); border: 1px solid color-mix(in srgb, #4ade80 26%, transparent); }
+.hero-stat-delta.is-down { color: #f87171; background: color-mix(in srgb, #f87171 14%, transparent); border: 1px solid color-mix(in srgb, #f87171 26%, transparent); }
+.hero-stat-delta i { font-size: 0.62rem; }
+.hero-stat-delta em { font-style: normal; opacity: 0.62; margin-left: 2px; font-size: 0.92em; }
+.hero-stat-label { text-transform: uppercase; font-weight: 700; color: var(--text-muted); }
+@media (max-width: 420px) {
+ .hero-stat-delta { font-size: 0.55rem; padding: 1px 5px 1px 3px; }
+ .hero-stat-delta em { display: none; }
+}
@@ -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=177">
+ <link rel="stylesheet" href="/css/style.css?v=178">
<link rel="stylesheet" href="/css/home-fx.css?v=11">
<link rel="stylesheet" href="/css/home-fx.css?v=11">
<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=152" defer></script>
+<script src="/js/app.js?v=153" 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>
@@ -1422,6 +1422,21 @@ function renderHeroStats(stats) {
@@ -1422,6 +1422,21 @@ function renderHeroStats(stats) {
// custom tooltip (data-tip, see _heroTooltip) spells out 24h / 7d / 30d and what the
// custom tooltip (data-tip, see _heroTooltip) spells out 24h / 7d / 30d and what the
// number means. Every chip with a series behind it is clickable → over-time chart.
// number means. Every chip with a series behind it is clickable → over-time chart.
const recSub = (rec, u = '') => (rec && rec.w > 0) ? `+${_fmtCount(rec.w)}${u} in 7d` : '';
const recSub = (rec, u = '') => (rec && rec.w > 0) ? `+${_fmtCount(rec.w)}${u} in 7d` : '';
+ /**
+ * The 7-day change, as a direction rather than a sentence.
+ *
+ * "+2 in 7d" in muted grey reads as a footnote; an arrow and a tinted pill reads as movement,
+ * which is the whole reason the number is interesting. Counters here only ever go up, but the
+ * down case is handled so this works anywhere.
+ */
+ const recDelta = (rec, u = '') => {
+ const w = rec && Number(rec.w);
+ if (!Number.isFinite(w) || w === 0) return '';
+ const up = w > 0;
+ return `<span class="hero-stat-delta ${up ? 'is-up' : 'is-down'}" title="Change over the last 7 days">`
+ + `<i class="fa-solid ${up ? 'fa-caret-up' : 'fa-caret-down'}"></i>`
+ + `${up ? '+' : '-'}${_fmtCount(Math.abs(w))}${u}<em>7d</em></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 (sparkline)
const sub = r.sub || recSub(r.recent, r.unit || '');
const sub = r.sub || recSub(r.recent, r.unit || '');
@@ -1431,7 +1446,7 @@ function renderHeroStats(stats) {
@@ -1431,7 +1446,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 ? `<span class="hero-stat-sub">${sub}</span>` : ''}</div></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-numline"><span class="hero-stat-num" data-n="${r.num || 0}">0</span>${recDelta(r.recent, r.unit || '')}</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></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
@@ -1447,12 +1462,12 @@ function renderHeroStats(stats) {
@@ -1447,12 +1462,12 @@ function renderHeroStats(stats) {
{ 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: '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: 'viewersNow', icon: 'fa-eye', cls: stats.viewersNow > 0 ? 'hero-stat--live' : '', num: stats.viewersNow, label: 'Watching', title: 'Viewers watching right now' },
{ key: 'weeklyActive', icon: 'fa-fire', num: stats.weeklyActive, label: 'Active · 7d', title: 'People who chatted in the last 7 days' },
{ key: 'weeklyActive', icon: 'fa-fire', num: stats.weeklyActive, label: 'Active · 7d', title: 'People who chatted in the last 7 days' },
- { key: 'users', icon: 'fa-user-group', num: stats.users, label: 'Users', title: 'Accounts on OpenVibe.Live' },
+ { 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' },
{ key: 'weeklyVisitors', icon: 'fa-user-plus', num: stats.weeklyVisitors, label: 'Visitors · 7d', title: 'First-time visitors in the last 7 days' },
- { key: 'anons', icon: 'fa-user-secret', num: stats.anons, label: 'Anons', title: 'Anonymous chatters who have been given a name' },
- { key: 'chatMessages', icon: 'fa-comments', num: stats.chatMessages, label: 'Messages', title: 'Chat messages sent, all time' },
+ { key: 'anons', icon: 'fa-user-secret', num: stats.anons, label: 'Anons', title: 'Anonymous chatters who have been given a name', recent: R.anons },
+ { key: 'chatMessages', icon: 'fa-comments', num: stats.chatMessages, label: 'Messages', title: 'Chat messages sent, all time', recent: R.messages },
{ key: 'streamers', icon: 'fa-satellite-dish', num: stats.streamers, label: 'Streamers', title: 'People who have gone live here' },
{ key: 'streamers', icon: 'fa-satellite-dish', num: stats.streamers, label: 'Streamers', title: 'People who have gone live here' },
- { key: 'hoursWatched', icon: 'fa-couch', num: stats.hoursWatched, label: 'Hours watched', title: 'Hours the community has spent watching' },
+ { key: 'hoursWatched', icon: 'fa-couch', num: stats.hoursWatched, label: 'Hours watched', title: 'Hours the community has spent watching', recent: R.hours },
];
];
const open = (() => { try { return localStorage.getItem('ov_stats_open') === '1'; } catch { return false; } })();
const open = (() => { try { return localStorage.getItem('ov_stats_open') === '1'; } catch { return false; } })();
wrap.innerHTML = `
wrap.innerHTML = `
@@ -1519,7 +1534,21 @@ function _startHeroStatsLive() {
@@ -1519,7 +1534,21 @@ function _startHeroStatsLive() {
try {
try {
const d = await api('/home/stats-live');
const d = await api('/home/stats-live');
_heroStatsBackoff = 0;
_heroStatsBackoff = 0;
+ // Deltas move too — refresh them in place rather than rebuilding the chip.
+ const DELTA_FOR = { users: 'users', anons: 'anons', chatMessages: 'messages', hoursWatched: 'hours' };
+ for (const [key, field] of Object.entries(DELTA_FOR)) {
+ const w = d && d.recent && d.recent[field] && Number(d.recent[field].w);
+ wrap.querySelectorAll(`[data-stat="${key}"] .hero-stat-delta`).forEach(el => {
+ if (!Number.isFinite(w) || w === 0) return;
+ const txt = `${w > 0 ? '+' : '-'}${_fmtCount(Math.abs(w))}`;
+ const cur = el.childNodes[1];
+ if (cur && cur.nodeValue !== txt) cur.nodeValue = txt;
+ el.classList.toggle('is-up', w > 0);
+ el.classList.toggle('is-down', w < 0);
+ });
+ }
for (const [key, value] of Object.entries(d || {})) {
for (const [key, value] of Object.entries(d || {})) {
+ if (key === 'recent') continue;
wrap.querySelectorAll(`[data-stat="${key}"] .hero-stat-num`).forEach(el => {
wrap.querySelectorAll(`[data-stat="${key}"] .hero-stat-num`).forEach(el => {
if (Number(el.dataset.n) === Number(value)) return;
if (Number(el.dataset.n) === Number(value)) return;
if (window.OVNum) OVNum.set(el, value); else el.textContent = _heroStatFmt(value);
if (window.OVNum) OVNum.set(el, value); else el.textContent = _heroStatFmt(value);
@@ -220,6 +220,12 @@ router.get('/stats-live', (req, res) => {
@@ -220,6 +220,12 @@ router.get('/stats-live', (req, res) => {
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,
+ // 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.
+ recent: {
+ users: s.recent?.users, anons: s.recent?.anons,
+ messages: s.recent?.messages, hours: s.recent?.hours,
+ },
};
};
_liveStats = { at: Date.now(), data };
_liveStats = { at: Date.now(), data };
res.json(data);
res.json(data);