openvibers/openvibe.live Commits
Hero deltas: gain and trend on every chip, and a triangle that isn't a font glyph
Changes
@@@ -15049,14 +15049,31 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srgfont-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-delta.is-up { color: #4ade80; background: color-mix(in srgb, #4ade80 13%, transparent); border: 1px solid color-mix(in srgb, #4ade80 24%, transparent); }++.hero-stat-delta.is-down { color: #f87171; background: color-mix(in srgb, #f87171 13%, transparent); border: 1px solid color-mix(in srgb, #f87171 24%, transparent); }++.hero-stat-delta.is-flat { color: var(--text-muted); background: color-mix(in srgb, var(--text-muted) 10%, transparent); border: 1px solid color-mix(in srgb, var(--text-muted) 20%, transparent); }++.hero-stat-delta b { font-weight: 850; }++.hero-stat-delta em { font-style: normal; opacity: 0.6; margin-left: 2px; font-size: 0.92em; }++/* The change against the previous seven days, so the gain has something to be measured against.++ The triangle is drawn with borders rather than set as an icon glyph — at 5px an icon font is++ a blurry smudge, and this needs no font to have loaded. */++.hero-stat-trend {++ display: inline-flex; align-items: center; gap: 3px;++ margin-left: 5px; padding-left: 5px;++ border-left: 1px solid currentColor; font-weight: 750;++}++.hero-stat-trend i { width: 0; height: 0; border-left: 3.5px solid transparent; border-right: 3.5px solid transparent; }++.hero-stat-trend.is-up { color: #4ade80; } .hero-stat-trend.is-up i { border-bottom: 5px solid currentColor; }++.hero-stat-trend.is-down { color: #f87171; } .hero-stat-trend.is-down i { border-top: 5px solid currentColor; }++.hero-stat-trend.is-flat { color: var(--text-muted); } .hero-stat-trend.is-flat i { width: 6px; height: 2px; border: 0; background: currentColor; border-radius: 1px; }++.hero-stat-trend.is-new { color: #fbbf24; text-transform: uppercase; letter-spacing: 0.06em; font-size: 0.9em; }.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; }++@media (max-width: 480px) {++ .hero-stat-delta { font-size: 0.55rem; padding: 1px 5px; }.hero-stat-delta em { display: none; }++ /* Two figures will not fit in a third of a phone screen; the gain is the one that matters,++ and the trend is still in the chip's tooltip. */++ .hero-stat-trend { 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=180">++ <link rel="stylesheet" href="/css/style.css?v=181"><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=156" defer></script>++<script src="/js/app.js?v=157" 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>@@@ -1423,19 +1423,36 @@ function renderHeroStats(stats) {// 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.++ * The seven-day picture on a chip: how much it moved, and whether that beat the week before.*-- * "+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.++ * One number on its own says nothing — "+2 this week" could be a record or a collapse. So the++ * pill carries both: the gain over the last seven days, and next to it the change against the++ * seven days before that, as a percentage. The triangle is drawn in CSS rather than set as an++ * icon glyph, so it stays crisp and tiny at this size.++ *++ * `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 recDelta = (rec, u = '') => {-- const w = rec && Number(rec.w);-- if (!Number.isFinite(w) || w === 0) return '';++ if (!rec) return '';++ const w = Number(rec.w), pw = Number(rec.pw);++ if (!Number.isFinite(w)) return '';++ if (w === 0 && !Number.isFinite(pw)) 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 prevText = Number.isFinite(pw) ? `, against ${_fmtCount(pw)} the seven days before` : '';++ const tip = `${_fmtCount(Math.abs(w))} in the last 7 days${prevText}`;++ return `<span class="hero-stat-delta ${w === 0 ? 'is-flat' : up ? 'is-up' : 'is-down'}" title="${esc(tip)}">`++ + `<b>${w === 0 ? '0' : (up ? '+' : '-') + _fmtCount(Math.abs(w))}${u}</b><em>7d</em>`++ + trendBits(w, pw)++ + '</span>';};const chip = (r) => {if (r.html) return r.html; // pre-rendered chips (sparkline)@@@ -1461,12 +1478,12 @@ function renderHeroStats(stats) {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: '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', 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' },++ { 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: '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', recent: R.streamers },{ key: 'hoursWatched', icon: 'fa-couch', num: stats.hoursWatched, label: 'Hours watched', title: 'Hours the community has spent watching', recent: R.hours },];// Always starts collapsed. Persisting "expanded" meant every reload rendered and counted up@@@ -1579,16 +1596,23 @@ function _startHeroStatsLive() {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);++ const DELTA_FOR = {++ users: d.recent?.users, anons: d.recent?.anons,++ chatMessages: d.recent?.messages, hoursWatched: d.recent?.hours,++ streamers: d.recent?.streamers,++ weeklyActive: { w: d.weeklyActive, pw: d.prevWeeklyActive },++ weeklyVisitors: { w: d.weeklyVisitors, pw: d.prevWeeklyVisitors },++ };++ for (const [key, rec] of Object.entries(DELTA_FOR)) {++ const w = rec && Number(rec.w);++ if (!Number.isFinite(w)) continue;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;++ const b = el.querySelector('b');++ const txt = w === 0 ? '0' : `${w > 0 ? '+' : '-'}${_fmtCount(Math.abs(w))}`;++ if (b && b.textContent !== txt) b.textContent = txt;el.classList.toggle('is-up', w > 0);el.classList.toggle('is-down', w < 0);++ el.classList.toggle('is-flat', w === 0);});}for (const [key, value] of Object.entries(d || {})) {@@@ -4059,15 +4059,26 @@ function _computeHomeStats() {// ISO timestamp). Everything Vibes-related on the hero starts counting from it.const vibesSince = vibesStatsSince();// Rolling day/week/month counts for a table by its timestamp column.++ // Rolling day/week/month counts, plus `pw`: the same seven-day window shifted back a week.++ // Without a previous period, "+2 in 7d" is a number with nothing to compare it to — you can't++ // tell whether things are speeding up or slowing down, which is the only interesting part.const winCount = (table, col, extra = '') => {const q = (w) => c(`SELECT COUNT(*) AS count FROM ${table} WHERE ${col} >= datetime('now', ?)${extra ? ' AND ' + extra : ''}`, [w]);-- return { d: q('-1 day'), w: q('-7 days'), m: q('-30 days') };++ const prev = c(`SELECT COUNT(*) AS count FROM ${table} WHERE ${col} >= datetime('now', '-14 days') AND ${col} < datetime('now', '-7 days')${extra ? ' AND ' + extra : ''}`);++ return { d: q('-1 day'), w: q('-7 days'), m: q('-30 days'), pw: prev };};const hoursSince = (w) => Math.round(c(`SELECT COALESCE(SUM(duration_seconds), 0) AS count FROM vods WHERE COALESCE(is_recording, 0) = 0 AND created_at >= datetime('now', ?)`, [w]) / 3600);++ const hoursPrevWeek = () => Math.round(c(`SELECT COALESCE(SUM(duration_seconds), 0) AS count FROM vods WHERE COALESCE(is_recording, 0) = 0 AND created_at >= datetime('now', '-14 days') AND created_at < datetime('now', '-7 days')`) / 3600);// Rolling day/week/month SUMS (for value metrics like Vibes tipped).const winSum = (table, col, tsCol, extra = '') => {const q = (w) => c(`SELECT COALESCE(SUM(${col}), 0) AS count FROM ${table} WHERE ${tsCol} >= datetime('now', ?)${extra ? ' AND ' + extra : ''}`, [w]);-- return { d: q('-1 day'), w: q('-7 days'), m: q('-30 days') };++ const prev = c(`SELECT COALESCE(SUM(${col}), 0) AS count FROM ${table} WHERE ${tsCol} >= datetime('now', '-14 days') AND ${tsCol} < datetime('now', '-7 days')${extra ? ' AND ' + extra : ''}`);++ return { d: q('-1 day'), w: q('-7 days'), m: q('-30 days'), pw: prev };++ };++ // Distinct people who went live in a window — "streamers" is a headcount, not a stream count.++ const streamersWin = () => {++ const q = (a, b) => 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: q('-1 day'), w: q('-7 days'), m: q('-30 days'), pw: q('-14 days', '-7 days') };};return {// ── Right-now + community-economy metrics ────────────────@@@ -4121,6 +4132,19 @@ function _computeHomeStats() {// New unique visitors this week (first-seen anon fingerprints) — a proxy for people who// showed up, not just those who chatted.weeklyVisitors: c(`SELECT COUNT(*) AS count FROM anon_ip_mappings WHERE created_at >= datetime('now', '-7 days')`),++ // The same two windows again, shifted back a week, so the hero can say whether this week++ // beat last week rather than just how big it was.++ prevWeeklyVisitors: c(`SELECT COUNT(*) AS count FROM anon_ip_mappings WHERE created_at >= datetime('now', '-14 days') AND created_at < datetime('now', '-7 days')`),++ prevWeeklyActive: c(`SELECT COUNT(*) AS count FROM (++ SELECT DISTINCT 'u' || user_id AS id FROM chat_messages++ WHERE user_id IS NOT NULL AND COALESCE(is_deleted, 0) = 0 AND timestamp >= datetime('now', '-14 days') AND timestamp < datetime('now', '-7 days')++ UNION++ SELECT DISTINCT 'a' || anon_id FROM chat_messages++ WHERE anon_id IS NOT NULL AND COALESCE(is_deleted, 0) = 0 AND timestamp >= datetime('now', '-14 days') AND timestamp < datetime('now', '-7 days')++ UNION++ SELECT DISTINCT 'r' || source_platform || '|' || username FROM chat_messages++ WHERE source_platform IS NOT NULL AND source_platform <> '' AND COALESCE(is_deleted, 0) = 0 AND timestamp >= datetime('now', '-14 days') AND timestamp < datetime('now', '-7 days')++ )`),liveNow: c(`SELECT COUNT(*) AS count FROM streams WHERE is_live = 1`),// Rolling last-day / week / month deltas ({ d, w, m }) for the hero stat tooltips + subs.recent: {@@@ -4131,7 +4155,8 @@ function _computeHomeStats() {clips: winCount('clips', 'created_at', 'COALESCE(is_public, 1) = 1'),aiMoments: winCount('stream_memories', 'created_at'),messages: winCount('chat_messages', 'timestamp', 'COALESCE(is_deleted, 0) = 0'),-- hours: { d: hoursSince('-1 day'), w: hoursSince('-7 days'), m: hoursSince('-30 days') },++ hours: { d: hoursSince('-1 day'), w: hoursSince('-7 days'), m: hoursSince('-30 days'), pw: hoursPrevWeek() },++ streamers: streamersWin(),vibes: winSum('transactions', 'amount', 'created_at', `type = 'donation' AND created_at >= '${vibesSince}'`),points: winSum('coin_transactions', 'amount', 'created_at', 'amount > 0'),pointsSpent: winSum('coin_transactions', '-amount', 'created_at', 'amount < 0'),@@@ -222,9 +222,11 @@ router.get('/stats-live', (req, res) => {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.++ prevWeeklyVisitors: s.prevWeeklyVisitors, prevWeeklyActive: s.prevWeeklyActive,recent: {users: s.recent?.users, anons: s.recent?.anons,messages: s.recent?.messages, hours: s.recent?.hours,++ streamers: s.recent?.streamers,},};_liveStats = { at: Date.now(), data };
@@ -15049,14 +15049,31 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
@@ -15049,14 +15049,31 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
font-size: 0.6rem; font-weight: 800; letter-spacing: 0.01em; white-space: nowrap;
font-size: 0.6rem; font-weight: 800; letter-spacing: 0.01em; white-space: nowrap;
align-self: center;
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-delta.is-up { color: #4ade80; background: color-mix(in srgb, #4ade80 13%, transparent); border: 1px solid color-mix(in srgb, #4ade80 24%, transparent); }
+.hero-stat-delta.is-down { color: #f87171; background: color-mix(in srgb, #f87171 13%, transparent); border: 1px solid color-mix(in srgb, #f87171 24%, transparent); }
+.hero-stat-delta.is-flat { color: var(--text-muted); background: color-mix(in srgb, var(--text-muted) 10%, transparent); border: 1px solid color-mix(in srgb, var(--text-muted) 20%, transparent); }
+.hero-stat-delta b { font-weight: 850; }
+.hero-stat-delta em { font-style: normal; opacity: 0.6; margin-left: 2px; font-size: 0.92em; }
+/* The change against the previous seven days, so the gain has something to be measured against.
+ The triangle is drawn with borders rather than set as an icon glyph — at 5px an icon font is
+ a blurry smudge, and this needs no font to have loaded. */
+.hero-stat-trend {
+ display: inline-flex; align-items: center; gap: 3px;
+ margin-left: 5px; padding-left: 5px;
+ border-left: 1px solid currentColor; font-weight: 750;
+}
+.hero-stat-trend i { width: 0; height: 0; border-left: 3.5px solid transparent; border-right: 3.5px solid transparent; }
+.hero-stat-trend.is-up { color: #4ade80; } .hero-stat-trend.is-up i { border-bottom: 5px solid currentColor; }
+.hero-stat-trend.is-down { color: #f87171; } .hero-stat-trend.is-down i { border-top: 5px solid currentColor; }
+.hero-stat-trend.is-flat { color: var(--text-muted); } .hero-stat-trend.is-flat i { width: 6px; height: 2px; border: 0; background: currentColor; border-radius: 1px; }
+.hero-stat-trend.is-new { color: #fbbf24; text-transform: uppercase; letter-spacing: 0.06em; font-size: 0.9em; }
.hero-stat-label { text-transform: uppercase; font-weight: 700; color: var(--text-muted); }
.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; }
+@media (max-width: 480px) {
+ .hero-stat-delta { font-size: 0.55rem; padding: 1px 5px; }
.hero-stat-delta em { display: none; }
.hero-stat-delta em { display: none; }
+ /* Two figures will not fit in a third of a phone screen; the gain is the one that matters,
+ and the trend is still in the chip's tooltip. */
+ .hero-stat-trend { 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=180">
+ <link rel="stylesheet" href="/css/style.css?v=181">
<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=156" defer></script>
+<script src="/js/app.js?v=157" 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>
@@ -1423,19 +1423,36 @@ function renderHeroStats(stats) {
@@ -1423,19 +1423,36 @@ function renderHeroStats(stats) {
// 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.
+ * The seven-day picture on a chip: how much it moved, and whether that beat the week before.
*
*
- * "+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.
+ * One number on its own says nothing — "+2 this week" could be a record or a collapse. So the
+ * pill carries both: the gain over the last seven days, and next to it the change against the
+ * seven days before that, as a percentage. The triangle is drawn in CSS rather than set as an
+ * icon glyph, so it stays crisp and tiny at this size.
+ *
+ * `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 recDelta = (rec, u = '') => {
const recDelta = (rec, u = '') => {
- const w = rec && Number(rec.w);
- if (!Number.isFinite(w) || w === 0) return '';
+ if (!rec) return '';
+ const w = Number(rec.w), pw = Number(rec.pw);
+ if (!Number.isFinite(w)) return '';
+ if (w === 0 && !Number.isFinite(pw)) return '';
const up = w > 0;
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 prevText = Number.isFinite(pw) ? `, against ${_fmtCount(pw)} the seven days before` : '';
+ const tip = `${_fmtCount(Math.abs(w))} in the last 7 days${prevText}`;
+ return `<span class="hero-stat-delta ${w === 0 ? 'is-flat' : up ? 'is-up' : 'is-down'}" title="${esc(tip)}">`
+ + `<b>${w === 0 ? '0' : (up ? '+' : '-') + _fmtCount(Math.abs(w))}${u}</b><em>7d</em>`
+ + trendBits(w, pw)
+ + '</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)
@@ -1461,12 +1478,12 @@ function renderHeroStats(stats) {
@@ -1461,12 +1478,12 @@ function renderHeroStats(stats) {
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: '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', 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' },
+ { 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: 'anons', icon: 'fa-user-secret', num: stats.anons, label: 'Anons', title: 'Anonymous chatters who have been given a name', recent: R.anons },
{ 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: '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', recent: R.streamers },
{ key: 'hoursWatched', icon: 'fa-couch', num: stats.hoursWatched, label: 'Hours watched', title: 'Hours the community has spent watching', recent: R.hours },
{ key: 'hoursWatched', icon: 'fa-couch', num: stats.hoursWatched, label: 'Hours watched', title: 'Hours the community has spent watching', recent: R.hours },
];
];
// Always starts collapsed. Persisting "expanded" meant every reload rendered and counted up
// Always starts collapsed. Persisting "expanded" meant every reload rendered and counted up
@@ -1579,16 +1596,23 @@ function _startHeroStatsLive() {
@@ -1579,16 +1596,23 @@ function _startHeroStatsLive() {
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.
// 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);
+ const DELTA_FOR = {
+ users: d.recent?.users, anons: d.recent?.anons,
+ chatMessages: d.recent?.messages, hoursWatched: d.recent?.hours,
+ streamers: d.recent?.streamers,
+ weeklyActive: { w: d.weeklyActive, pw: d.prevWeeklyActive },
+ weeklyVisitors: { w: d.weeklyVisitors, pw: d.prevWeeklyVisitors },
+ };
+ for (const [key, rec] of Object.entries(DELTA_FOR)) {
+ const w = rec && Number(rec.w);
+ if (!Number.isFinite(w)) continue;
wrap.querySelectorAll(`[data-stat="${key}"] .hero-stat-delta`).forEach(el => {
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;
+ const b = el.querySelector('b');
+ const txt = w === 0 ? '0' : `${w > 0 ? '+' : '-'}${_fmtCount(Math.abs(w))}`;
+ if (b && b.textContent !== txt) b.textContent = txt;
el.classList.toggle('is-up', w > 0);
el.classList.toggle('is-up', w > 0);
el.classList.toggle('is-down', w < 0);
el.classList.toggle('is-down', w < 0);
+ el.classList.toggle('is-flat', w === 0);
});
});
}
}
for (const [key, value] of Object.entries(d || {})) {
for (const [key, value] of Object.entries(d || {})) {
@@ -4059,15 +4059,26 @@ function _computeHomeStats() {
@@ -4059,15 +4059,26 @@ function _computeHomeStats() {
// ISO timestamp). Everything Vibes-related on the hero starts counting from it.
// ISO timestamp). Everything Vibes-related on the hero starts counting from it.
const vibesSince = vibesStatsSince();
const vibesSince = vibesStatsSince();
// Rolling day/week/month counts for a table by its timestamp column.
// Rolling day/week/month counts for a table by its timestamp column.
+ // Rolling day/week/month counts, plus `pw`: the same seven-day window shifted back a week.
+ // Without a previous period, "+2 in 7d" is a number with nothing to compare it to — you can't
+ // tell whether things are speeding up or slowing down, which is the only interesting part.
const winCount = (table, col, extra = '') => {
const winCount = (table, col, extra = '') => {
const q = (w) => c(`SELECT COUNT(*) AS count FROM ${table} WHERE ${col} >= datetime('now', ?)${extra ? ' AND ' + extra : ''}`, [w]);
const q = (w) => c(`SELECT COUNT(*) AS count FROM ${table} WHERE ${col} >= datetime('now', ?)${extra ? ' AND ' + extra : ''}`, [w]);
- return { d: q('-1 day'), w: q('-7 days'), m: q('-30 days') };
+ const prev = c(`SELECT COUNT(*) AS count FROM ${table} WHERE ${col} >= datetime('now', '-14 days') AND ${col} < datetime('now', '-7 days')${extra ? ' AND ' + extra : ''}`);
+ return { d: q('-1 day'), w: q('-7 days'), m: q('-30 days'), pw: prev };
};
};
const hoursSince = (w) => Math.round(c(`SELECT COALESCE(SUM(duration_seconds), 0) AS count FROM vods WHERE COALESCE(is_recording, 0) = 0 AND created_at >= datetime('now', ?)`, [w]) / 3600);
const hoursSince = (w) => Math.round(c(`SELECT COALESCE(SUM(duration_seconds), 0) AS count FROM vods WHERE COALESCE(is_recording, 0) = 0 AND created_at >= datetime('now', ?)`, [w]) / 3600);
+ const hoursPrevWeek = () => Math.round(c(`SELECT COALESCE(SUM(duration_seconds), 0) AS count FROM vods WHERE COALESCE(is_recording, 0) = 0 AND created_at >= datetime('now', '-14 days') AND created_at < datetime('now', '-7 days')`) / 3600);
// Rolling day/week/month SUMS (for value metrics like Vibes tipped).
// Rolling day/week/month SUMS (for value metrics like Vibes tipped).
const winSum = (table, col, tsCol, extra = '') => {
const winSum = (table, col, tsCol, extra = '') => {
const q = (w) => c(`SELECT COALESCE(SUM(${col}), 0) AS count FROM ${table} WHERE ${tsCol} >= datetime('now', ?)${extra ? ' AND ' + extra : ''}`, [w]);
const q = (w) => c(`SELECT COALESCE(SUM(${col}), 0) AS count FROM ${table} WHERE ${tsCol} >= datetime('now', ?)${extra ? ' AND ' + extra : ''}`, [w]);
- return { d: q('-1 day'), w: q('-7 days'), m: q('-30 days') };
+ const prev = c(`SELECT COALESCE(SUM(${col}), 0) AS count FROM ${table} WHERE ${tsCol} >= datetime('now', '-14 days') AND ${tsCol} < datetime('now', '-7 days')${extra ? ' AND ' + extra : ''}`);
+ return { d: q('-1 day'), w: q('-7 days'), m: q('-30 days'), pw: prev };
+ };
+ // Distinct people who went live in a window — "streamers" is a headcount, not a stream count.
+ const streamersWin = () => {
+ const q = (a, b) => 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: q('-1 day'), w: q('-7 days'), m: q('-30 days'), pw: q('-14 days', '-7 days') };
};
};
return {
return {
// ── Right-now + community-economy metrics ────────────────
// ── Right-now + community-economy metrics ────────────────
@@ -4121,6 +4132,19 @@ function _computeHomeStats() {
@@ -4121,6 +4132,19 @@ function _computeHomeStats() {
// New unique visitors this week (first-seen anon fingerprints) — a proxy for people who
// New unique visitors this week (first-seen anon fingerprints) — a proxy for people who
// showed up, not just those who chatted.
// showed up, not just those who chatted.
weeklyVisitors: c(`SELECT COUNT(*) AS count FROM anon_ip_mappings WHERE created_at >= datetime('now', '-7 days')`),
weeklyVisitors: c(`SELECT COUNT(*) AS count FROM anon_ip_mappings WHERE created_at >= datetime('now', '-7 days')`),
+ // The same two windows again, shifted back a week, so the hero can say whether this week
+ // beat last week rather than just how big it was.
+ prevWeeklyVisitors: c(`SELECT COUNT(*) AS count FROM anon_ip_mappings WHERE created_at >= datetime('now', '-14 days') AND created_at < datetime('now', '-7 days')`),
+ prevWeeklyActive: c(`SELECT COUNT(*) AS count FROM (
+ SELECT DISTINCT 'u' || user_id AS id FROM chat_messages
+ WHERE user_id IS NOT NULL AND COALESCE(is_deleted, 0) = 0 AND timestamp >= datetime('now', '-14 days') AND timestamp < datetime('now', '-7 days')
+ UNION
+ SELECT DISTINCT 'a' || anon_id FROM chat_messages
+ WHERE anon_id IS NOT NULL AND COALESCE(is_deleted, 0) = 0 AND timestamp >= datetime('now', '-14 days') AND timestamp < datetime('now', '-7 days')
+ UNION
+ SELECT DISTINCT 'r' || source_platform || '|' || username FROM chat_messages
+ WHERE source_platform IS NOT NULL AND source_platform <> '' AND COALESCE(is_deleted, 0) = 0 AND timestamp >= datetime('now', '-14 days') AND timestamp < datetime('now', '-7 days')
+ )`),
liveNow: c(`SELECT COUNT(*) AS count FROM streams WHERE is_live = 1`),
liveNow: c(`SELECT COUNT(*) AS count FROM streams WHERE is_live = 1`),
// Rolling last-day / week / month deltas ({ d, w, m }) for the hero stat tooltips + subs.
// Rolling last-day / week / month deltas ({ d, w, m }) for the hero stat tooltips + subs.
recent: {
recent: {
@@ -4131,7 +4155,8 @@ function _computeHomeStats() {
@@ -4131,7 +4155,8 @@ function _computeHomeStats() {
clips: winCount('clips', 'created_at', 'COALESCE(is_public, 1) = 1'),
clips: winCount('clips', 'created_at', 'COALESCE(is_public, 1) = 1'),
aiMoments: winCount('stream_memories', 'created_at'),
aiMoments: winCount('stream_memories', 'created_at'),
messages: winCount('chat_messages', 'timestamp', 'COALESCE(is_deleted, 0) = 0'),
messages: winCount('chat_messages', 'timestamp', 'COALESCE(is_deleted, 0) = 0'),
- hours: { d: hoursSince('-1 day'), w: hoursSince('-7 days'), m: hoursSince('-30 days') },
+ hours: { d: hoursSince('-1 day'), w: hoursSince('-7 days'), m: hoursSince('-30 days'), pw: hoursPrevWeek() },
+ streamers: streamersWin(),
vibes: winSum('transactions', 'amount', 'created_at', `type = 'donation' AND created_at >= '${vibesSince}'`),
vibes: winSum('transactions', 'amount', 'created_at', `type = 'donation' AND created_at >= '${vibesSince}'`),
points: winSum('coin_transactions', 'amount', 'created_at', 'amount > 0'),
points: winSum('coin_transactions', 'amount', 'created_at', 'amount > 0'),
pointsSpent: winSum('coin_transactions', '-amount', 'created_at', 'amount < 0'),
pointsSpent: winSum('coin_transactions', '-amount', 'created_at', 'amount < 0'),
@@ -222,9 +222,11 @@ router.get('/stats-live', (req, res) => {
@@ -222,9 +222,11 @@ router.get('/stats-live', (req, res) => {
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
// 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,
recent: {
recent: {
users: s.recent?.users, anons: s.recent?.anons,
users: s.recent?.users, anons: s.recent?.anons,
messages: s.recent?.messages, hours: s.recent?.hours,
messages: s.recent?.messages, hours: s.recent?.hours,
+ streamers: s.recent?.streamers,
},
},
};
};
_liveStats = { at: Date.now(), data };
_liveStats = { at: Date.now(), data };