openvibers/openvibe.live Commits

Hero deltas: gain and trend on every chip, and a triangle that isn't a font glyph

OP OpenVibers committed 16 Sept 2026 · bece6dd563 · 5 files, +98 −30

"+2 in 7d" on its own says nothing — you can't tell whether that's a record week or a collapse.
Each chip now carries both: the gain over the last seven days, and beside it the change against
the seven days before, as a percentage. A window with no previous activity reads "new"; an
unchanged one reads "flat".

The server grew the second window to support it: winCount and winSum return a `pw` alongside
d/w/m, hours got a previous-week sum, streamers got a distinct-headcount window (it was counting
streams, not people), and the two chips that are themselves 7-day figures — active and visitors —
got their own previous-period queries.

Every headline chip has a delta now, not four of nine. Live and watching are deliberately
excluded: they are instantaneous readings, and a 7-day figure for them would be meaningless.

The caret icon is gone. The sign carries the direction for the gain, and the trend triangle is
drawn with CSS borders rather than set as an icon glyph — at five pixels an icon font is a blurry
smudge, and this needs no font to have loaded. Below 480px the trend hides rather than wrapping;
it stays in the chip's tooltip.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMxJm1dB8rdsaMSgNjVa82

Changes

ddiff --git a/public/css/style.css b/public/css/style.css
iindex 620475ca..8b67c139 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -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;
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; }
}
ddiff --git a/public/index.html b/public/index.html
iindex b76d55b7..766820c4 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -88,7 +88,7 @@
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. -->
<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>
ddiff --git a/public/js/app.js b/public/js/app.js
iindex f8cf2171..19ab9cfd 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -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 || {})) {
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 10affae1..5ce95a6c 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -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'),
ddiff --git a/server/home/routes.js b/server/home/routes.js
iindex cd9ab72b..baee9b68 100644
---- a/server/home/routes.js
++++ b/server/home/routes.js
@@@ -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 };