openvibers/openvibe.live Commits

A tip page from the dashboard, signed-in viewers in the first-stream follow-up, and the analytics summary from the daily rollups (#311)

OP OpenVibers committed 22 days ago · 08cd3c73de · 8 files, +112 −19

Changes

ddiff --git a/public/css/features/dashboard.css b/public/css/features/dashboard.css
iindex 71b32219..d0d52d2c 100644
---- a/public/css/features/dashboard.css
++++ b/public/css/features/dashboard.css
@@@ -375,6 +375,7 @@
}
.dash-profile-head .settings-banner-name { font-size:1.15rem; font-weight:700; color:var(--text); }
.settings-avatar-edit:hover { background:var(--accent-dark,var(--accent-dark)); color:#fff; }
++.dash-tips-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
.dash-goal { display: flex; gap: 12px; align-items: center; padding: 10px; border: 1px solid var(--border); border-radius: 10px; margin-bottom: 10px; background: var(--bg-secondary); }
.dash-goal-thumb { width: 64px; height: 64px; border-radius: 8px; object-fit: cover; flex-shrink: 0; background: #000; }
.dash-goal-thumb-ph { display: flex; align-items: center; justify-content: center; color: var(--text-muted); font-size: 1.3rem; background: var(--bg-tertiary); }
ddiff --git a/public/fragments/dashboard.html b/public/fragments/dashboard.html
iindex 0d89dade..bc8483d0 100644
---- a/public/fragments/dashboard.html
++++ b/public/fragments/dashboard.html
@@@ -109,6 +109,13 @@
</button>
</div>
++ <!-- Tip page on OpenVibe.Tips (dashboard.js loadDashTipsPage) -->
++ <div class="dash-card" id="dash-card-tips-page">
++ <h3><i class="fa-solid fa-hand-holding-heart"></i> Your tip page <span class="muted">openvibe.tips</span></h3>
++ <p class="muted" style="font-size:0.9rem">A page of your own for tips, paid messages, text-to-speech and sounds, with overlays for OBS. Tips there count toward your goals here and show in your chat, and your channel's Donate window links to it.</p>
++ <div id="dash-tips-page-status"><p class="muted">Loading…</p></div>
++ </div>
++
<!-- PowerChat donations -->
<div class="dash-card" id="dash-powerchat-card" style="display:none">
<h3><i class="fa-solid fa-hand-holding-dollar"></i> PowerChat Tips <span class="muted">real donations</span></h3>
ddiff --git a/public/js/dashboard.js b/public/js/dashboard.js
iindex fad99dd7..eb3fafd7 100644
---- a/public/js/dashboard.js
++++ b/public/js/dashboard.js
@@@ -15,7 +15,7 @@ const _DASH_TAB_CARDS = {
chatai: ['dash-aibot-card', 'dash-card-overlay', 'dash-card-chatlogs'],
moderation: [], // injected by dashboard-moderation.js into #dash-grid-moderation
controls: ['dash-card-bot-embed', 'dash-card-tokens'],
-- money: ['dash-card-bucks', 'dash-card-goals', 'dash-powerchat-card'],
++ money: ['dash-card-bucks', 'dash-card-goals', 'dash-card-tips-page', 'dash-powerchat-card'],
points: ['dash-card-points-config', 'dash-card-rewards', 'dash-card-redemptions'],
content: [], // content cards go into sub-grids (see _DASH_CONTENT_SUBCARDS)
};
@@@ -103,7 +103,7 @@ function _call(name) { try { if (typeof window[name] === 'function') window[name
window._dashTabLoaders.home = () => { _call('loadSettingsProfile'); _call('loadAiDerivationSetting'); _call('loadSettingsOffline'); _call('loadDashFunds'); };
window._dashTabLoaders.chatai = () => { _call('updateDashObsOverlayUrl'); _call('loadAiViewers'); };
window._dashTabLoaders.controls = () => { _call('loadBotEmbedSetting'); _call('loadDashTokens'); };
--window._dashTabLoaders.money = () => { _call('loadDashFunds'); _call('loadDashGoals'); _call('loadPowerchatStatus'); };
++window._dashTabLoaders.money = () => { _call('loadDashFunds'); _call('loadDashGoals'); _call('loadDashTipsPage'); _call('loadPowerchatStatus'); };
window._dashTabLoaders.points = () => { _call('loadDashPointsConfig'); _call('loadDashRewards'); _call('loadDashRedemptions'); };
window._dashTabLoaders.content = () => { switchDashContentTab('videos', document.querySelector('#dash-content-subtabs .ch-tab[data-cdtab="videos"]')); _call('loadDashAiMomentsCount'); };
// moderation loader is registered by dashboard-moderation.js
@@@ -274,6 +274,29 @@ function showEndpointInfo(stream) {
el.textContent = info;
}
++/* ── Tip page on OpenVibe.Tips ─────────────────────────────────── */
++// The channel links a creator's Tips page once it is on (server/monetization/elsewhere.js). This card says whether it
++// is, and starts it in one step: openvibe.tips/start, signed in with the same account, one button. DOM nodes only.
++async function loadDashTipsPage() {
++ const box = document.getElementById('dash-tips-page-status');
++ if (!box || !currentUser || !currentUser.username) return;
++ let d = null;
++ try { d = await api(`/payments/channel/${encodeURIComponent(currentUser.username)}/elsewhere`); } catch { d = null; }
++ const link = (href, text, cls) => { const a = document.createElement('a'); a.href = href; a.target = '_blank'; a.rel = 'noopener'; if (cls) a.className = cls; a.textContent = text; return a; };
++ const p = document.createElement('p');
++ const row = document.createElement('div');
++ row.className = 'dash-tips-actions';
++ if (d && d.tips_url) {
++ p.append('On at ', link(d.tips_url, d.tips_url.replace(/^https:\/\//, '')), '.');
++ row.append(link(d.tips_url, 'Open my page', 'btn btn-outline'), link('https://openvibe.tips/dashboard', 'Settings and overlays', 'btn btn-outline'));
++ } else {
++ p.className = 'muted';
++ p.textContent = 'Not on yet. One step: the page is made from this account.';
++ row.append(link('https://openvibe.tips/start', 'Turn on my tip page', 'btn btn-primary'));
++ }
++ box.replaceChildren(p, row);
++}
++
/* ── Goals ─────────────────────────────────────────────────────── */
async function loadDashGoals() {
if (!currentUser) return;
ddiff --git a/server/analytics/summary-cache.js b/server/analytics/summary-cache.js
iindex 9ff977fc..17b3a048 100644
---- a/server/analytics/summary-cache.js
++++ b/server/analytics/summary-cache.js
@@@ -1,13 +1,28 @@
'use strict';
/**
-- * The totals behind GET /internal/analytics-summary (Network orders its navigation by real use). The full stats they
-- * come from took ~4 s of database time (p95, 2026-10-17) for a few numbers Network reads every so often, so each
-- * window is computed at most once per `ttlMs`, and concurrent callers share one computation. A failed computation is
-- * not kept: the next call tries again.
++ * The totals behind GET /internal/analytics-summary (Network orders its navigation by real use). They come from the
++ * daily rollups alone (rollupSummary): the shared tracker's full stats gave the same totals from the same rollups, but
++ * ran about twenty raw-event queries for everything else first (~4 s of database time at p95, 2026-10-17). Each window
++ * is still computed at most once per `ttlMs`, and concurrent callers share one computation. A failed computation is not
++ * kept: the next call tries again.
*
-- * const summary = createSummaryCache((days) => analytics.getStats({ days }));
++ * const summary = createSummaryCache(async (days) => ({ summary: await rollupSummary(db, { days }) }));
* await summary(7) // → { … } (the stats' `summary`, or {})
*/
++const DAY_MS = 24 * 60 * 60 * 1000;
++
++/**
++ * A window's totals from analytics_daily: the same row openvibe-shared/analytics/pg getStats({ days }) puts in
++ * `summary` (its rollup branch, the one every days-window takes), one query on a table of a row a day. → the row, or {}.
++ */
++async function rollupSummary(db, { days, service = 'live', now = Date.now() } = {}) {
++ const since = new Date(now - days * DAY_MS).toISOString().slice(0, 10);
++ const row = await db.maybe(`SELECT SUM(pageviews)::bigint AS total_pageviews, SUM(api_calls)::bigint AS total_api_calls, SUM(unique_visitors)::bigint AS total_unique_visitors,
++ SUM(unique_users)::bigint AS total_unique_users, SUM(bot_hits)::bigint AS total_bot_hits, SUM(error_count)::bigint AS total_errors,
++ CAST(AVG(avg_response_ms) AS integer) AS avg_response_ms
++ FROM analytics_daily WHERE service = $1 AND date >= $2`, [service, since]);
++ return row || {};
++}
function createSummaryCache(getStats, { ttlMs = 600_000, now = () => Date.now() } = {}) {
const entries = new Map(); // days -> { at, promise }
return function summary(days) {
@@@ -22,4 +37,4 @@ function createSummaryCache(getStats, { ttlMs = 600_000, now = () => Date.now()
};
}
--module.exports = { createSummaryCache };
++module.exports = { createSummaryCache, rollupSummary };
ddiff --git a/server/index.js b/server/index.js
iindex 9fb0c2d4..fb0e6090 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -709,8 +709,9 @@ app.post('/internal/openre-events', require('./openre/mirror').webhookHandler);
// Internal (server-to-server) routes — allow openvibe.network to call into this service
// Summary numbers for the Network's navigation service (ordering sites by real use).
// A service token with live.analytics.read (loopback only); returns totals, never rows.
--// One computation per window per 10 minutes (./analytics/summary-cache): the full stats take ~4 s of database time.
--const analyticsSummary = require('./analytics/summary-cache').createSummaryCache((days) => analytics.getStats({ days }));
++// The window's totals from the daily rollups, one computation per window per 10 minutes (./analytics/summary-cache).
++const { createSummaryCache, rollupSummary } = require('./analytics/summary-cache');
++const analyticsSummary = createSummaryCache(async (days) => ({ summary: await rollupSummary(db.getDb(), { days }) }));
app.get('/internal/analytics-summary', require('./net/service-guard').guard('live.analytics.read'), async (req, res) => {
try {
const days = Math.min(parseInt(req.query.days, 10) || 7, 90);
ddiff --git a/server/streaming/first-stream-followup.js b/server/streaming/first-stream-followup.js
iindex 9a4bee55..7f2b374c 100644
---- a/server/streaming/first-stream-followup.js
++++ b/server/streaming/first-stream-followup.js
@@@ -2,9 +2,10 @@
/**
* The first stream's follow-up (plan 2026-10-22, track C3). Of the 41 people whose first stream was in the last 30
* days (to 2026-10-22), 10 streamed again within 14 days; most first streams ran 1 to 10 minutes. After someone's
-- * first stream ends, they get one notification on the network: how long it ran, where the recording is (a public VOD
-- * on OpenVibe.Video once Media has it, else the after-show report, else their channel), and the channel address to
-- * share before going live again.
++ * first stream ends, they get one notification on the network: how long it ran, how many signed-in viewers watched
++ * (watch_time: one row per person per stream, the streamer left out; peak_viewers counts connections, not people),
++ * where the recording is (a public VOD on OpenVibe.Video once Media has it, else the after-show report, else their
++ * channel), and the channel address to share before going live again.
*
* run() every 5 minutes (server/index.js): first streams that ended 5 minutes to 2 hours ago, each notified once
* (app_state `first_stream_followup:<user id>`). A stream under 30 seconds is a test and gets nothing.
@@@ -25,14 +26,14 @@ function minutes(seconds) {
}
/** The notification for a first stream: { title, message, url }. */
--function messageFor({ username, seconds, vodUrl }) {
++function messageFor({ username, seconds, vodUrl, watchers = 0 }) {
const channel = `${LIVE_URL}/@${encodeURIComponent(username)}`;
const where = vodUrl ? 'The recording is on OpenVibe.Video, ready to watch, clip and share.'
: seconds >= RECAP_MIN_SECONDS ? 'Your after-show report is ready, and the recording follows on your channel.'
: 'The recording appears on your channel once it is ready.';
return {
title: 'Your first stream is done 🎉',
-- message: `${minutes(seconds)} live. ${where} Next time, share ${channel.replace('https://', '')} before you go live so people can find you.`,
++ message: `${minutes(seconds)} live${watchers > 0 ? `, watched by ${watchers} signed-in ${watchers === 1 ? 'viewer' : 'viewers'}` : ''}. ${where} Next time, share ${channel.replace('https://', '')} before you go live so people can find you.`,
url: vodUrl || null,
};
}
@@@ -58,7 +59,8 @@ async function run({ now = Date.now, push = null, vodFor = null } = {}) {
if (seconds < MIN_SECONDS) continue;
let vodUrl = null;
try { vodUrl = await findVod(s); } catch { /* Media unreachable: the channel link instead */ }
-- const m = messageFor({ username: s.username, seconds, vodUrl });
++ const seen = await db.get('SELECT COUNT(*) AS n FROM watch_time WHERE stream_id = ? AND user_id <> ?', [s.id, s.user_id]);
++ const m = messageFor({ username: s.username, seconds, vodUrl, watchers: Number(seen && seen.n) || 0 });
const url = m.url || (seconds >= RECAP_MIN_SECONDS ? `${LIVE_URL}/recap/${s.id}` : `${LIVE_URL}/@${encodeURIComponent(s.username)}`);
send({ user_id: s.user_id, type: 'STREAM_SUMMARY', category: 'stream', title: m.title, message: m.message, url });
sent++;
ddiff --git a/test/analytics-summary-rollups.test.js b/test/analytics-summary-rollups.test.js
nnew file mode 100644
iindex 00000000..00d072a9
---- /dev/null
++++ b/test/analytics-summary-rollups.test.js
@@@ -0,0 +1,36 @@
++'use strict';
++/**
++ * GET /internal/analytics-summary answers from the daily rollups alone (server/analytics/summary-cache.js rollupSummary):
++ * the same totals the shared tracker's full stats put in `summary` for a days-window, without its raw-event queries.
++ *
++ * node --import ./test/helpers/pg-preload.mjs test/analytics-summary-rollups.test.js
++ */
++const assert = require('assert');
++const db = require('../server/db/database');
++const { createAnalyticsStore } = require('../server/analytics/store');
++const { rollupSummary } = require('../server/analytics/summary-cache');
++
++async function main() {
++ await db.initDb();
++ const d = db.getDb();
++ const store = createAnalyticsStore({ timers: false });
++ try {
++ const day = (ago) => new Date(Date.now() - ago * 86_400_000).toISOString().slice(0, 10);
++ const row = (service, ago, n) => d.exec(`INSERT INTO analytics_daily (service, date, pageviews, api_calls, unique_visitors, unique_users, bot_hits, avg_response_ms, error_count)
++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (service, date) DO NOTHING`, [service, day(ago), n, n * 2, n + 1, 1, n * 3, 40 + n, 1]);
++ await row('live', 1, 10); await row('live', 3, 20); await row('live', 6, 30); await row('live', 20, 1000); await row('other', 2, 500);
++
++ const fromRollups = await rollupSummary(d, { days: 7 });
++ const fromStats = (await store.tracker.getStats({ days: 7 })).summary;
++ assert.deepStrictEqual(fromRollups, fromStats, 'the same totals as the shared stats\' summary');
++ assert.strictEqual(Number(fromRollups.total_pageviews), 60, 'the window\'s days only, this service only');
++ assert.strictEqual(Number(fromRollups.total_bot_hits), 180);
++ assert.strictEqual(Number((await rollupSummary(d, { days: 30 })).total_pageviews), 1060);
++ assert.deepStrictEqual(Object.keys(await rollupSummary(d, { days: 7, service: 'nobody' })).sort(), Object.keys(fromStats).sort(), 'no rows: the same shape, empty sums');
++ } finally {
++ await store.close();
++ }
++ console.log('analytics summary from rollups: all checks passed');
++}
++
++main().catch((err) => { console.error(err); process.exitCode = 1; });
ddiff --git a/test/first-stream-followup.test.js b/test/first-stream-followup.test.js
iindex f1fa092c..c64dbbec 100644
---- a/test/first-stream-followup.test.js
++++ b/test/first-stream-followup.test.js
@@@ -1,7 +1,8 @@
'use strict';
// The first stream's follow-up (server/streaming/first-stream-followup.js): one notification after someone's first
// stream ends (settled for 5 minutes), never twice, never for a second stream or a test under 30 seconds; it links the
--// public recording when Media has it, else the after-show report (8 minutes and up), else the channel.
++// public recording when Media has it, else the after-show report (8 minutes and up), else the channel; it counts the
++// signed-in viewers who watched (watch_time, the streamer left out), never peak_viewers.
const assert = require('assert');
const db = require('../server/db/database');
const followup = require('../server/streaming/first-stream-followup');
@@@ -32,6 +33,12 @@ const sqlTime = (ms) => new Date(ms).toISOString().replace('T', ' ').slice(0, 19
await mkStream(ed, { endedAgoMin: 5000, seconds: 900 });
await mkStream(ed, { endedAgoMin: 20, seconds: 900 });
++ // Ana's stream: two signed-in viewers watched, and her own row (watching herself) is not one of them.
++ const v1 = await mkUser('fs_v1');
++ const v2 = await mkUser('fs_v2');
++ for (const u of [v1, v2, ana]) await db.run('INSERT INTO watch_time (user_id, stream_id, minutes_watched) VALUES (?, ?, 3)', [u, anaStream]);
++ await db.run('UPDATE streams SET peak_viewers = 40 WHERE id = ?', [boStream]);
++
const sent = [];
const vodFor = async (s) => (s.id === anaStream ? 'https://openvibe.video/v/77' : null);
const out = await followup.run({ now: () => NOW, push: (n) => sent.push(n), vodFor });
@@@ -39,11 +46,11 @@ const sqlTime = (ms) => new Date(ms).toISOString().replace('T', ' ').slice(0, 19
const byUser = Object.fromEntries(sent.map((n) => [n.user_id, n]));
assert.deepStrictEqual(Object.keys(byUser).map(Number).sort(), [ana, bo].map(Number).sort());
assert.strictEqual(byUser[ana].url, 'https://openvibe.video/v/77');
-- assert.match(byUser[ana].message, /^6 minutes live\. The recording is on OpenVibe\.Video/);
++ assert.match(byUser[ana].message, /^6 minutes live, watched by 2 signed-in viewers\. The recording is on OpenVibe\.Video/);
assert.match(byUser[ana].message, /share openvibe\.live\/@fs_ana before you go live/);
assert.strictEqual(byUser[bo].url, `https://openvibe.live/recap/${boStream}`, 'no public recording yet: the after-show report');
assert.strictEqual(byUser[ana].type, 'STREAM_SUMMARY');
-- assert.ok(!/viewer/i.test(sent.map((n) => n.message).join(' ')), 'no viewer counts (peak_viewers is not a person count)');
++ assert.ok(!/viewer/i.test(byUser[bo].message), 'no signed-in viewer watched Bo: nothing said, and peak_viewers (not a person count) is never used');
// Never twice; the test stream was marked too, so it never comes back either.
assert.strictEqual((await followup.run({ now: () => NOW, push: (n) => sent.push(n), vodFor })).sent, 0);
@@@ -57,6 +64,7 @@ const sqlTime = (ms) => new Date(ms).toISOString().replace('T', ' ').slice(0, 19
// Short first stream with no recording: the channel.
const m = followup.messageFor({ username: 'zed', seconds: 90, vodUrl: null });
assert.match(m.message, /^2 minutes live\. The recording appears on your channel/);
++ assert.match(followup.messageFor({ username: 'zed', seconds: 90, vodUrl: null, watchers: 1 }).message, /^2 minutes live, watched by 1 signed-in viewer\. /);
console.log('first-stream follow-up: once, settled, recording/report/channel links, no test streams');
process.exit(0);
})().catch((e) => { console.error(e); process.exit(1); });