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)
Changes
@@@ -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); }@@@ -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>@@@ -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-moderationcontrols: ['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[namewindow._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;@@@ -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 };@@@ -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);@@@ -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++;nnew file mode 100644@@@ -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; });@@@ -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, 19await 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, 19const 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); });
@@ -375,6 +375,7 @@
@@ -375,6 +375,7 @@
}
}
.dash-profile-head .settings-banner-name { font-size:1.15rem; font-weight:700; color:var(--text); }
.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; }
.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 { 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 { 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); }
.dash-goal-thumb-ph { display: flex; align-items: center; justify-content: center; color: var(--text-muted); font-size: 1.3rem; background: var(--bg-tertiary); }
@@ -109,6 +109,13 @@
@@ -109,6 +109,13 @@
</button>
</button>
</div>
</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 -->
<!-- PowerChat donations -->
<div class="dash-card" id="dash-powerchat-card" style="display:none">
<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>
<h3><i class="fa-solid fa-hand-holding-dollar"></i> PowerChat Tips <span class="muted">real donations</span></h3>
@@ -15,7 +15,7 @@ const _DASH_TAB_CARDS = {
@@ -15,7 +15,7 @@ const _DASH_TAB_CARDS = {
chatai: ['dash-aibot-card', 'dash-card-overlay', 'dash-card-chatlogs'],
chatai: ['dash-aibot-card', 'dash-card-overlay', 'dash-card-chatlogs'],
moderation: [], // injected by dashboard-moderation.js into #dash-grid-moderation
moderation: [], // injected by dashboard-moderation.js into #dash-grid-moderation
controls: ['dash-card-bot-embed', 'dash-card-tokens'],
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'],
points: ['dash-card-points-config', 'dash-card-rewards', 'dash-card-redemptions'],
content: [], // content cards go into sub-grids (see _DASH_CONTENT_SUBCARDS)
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
@@ -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.home = () => { _call('loadSettingsProfile'); _call('loadAiDerivationSetting'); _call('loadSettingsOffline'); _call('loadDashFunds'); };
window._dashTabLoaders.chatai = () => { _call('updateDashObsOverlayUrl'); _call('loadAiViewers'); };
window._dashTabLoaders.chatai = () => { _call('updateDashObsOverlayUrl'); _call('loadAiViewers'); };
window._dashTabLoaders.controls = () => { _call('loadBotEmbedSetting'); _call('loadDashTokens'); };
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.points = () => { _call('loadDashPointsConfig'); _call('loadDashRewards'); _call('loadDashRedemptions'); };
window._dashTabLoaders.content = () => { switchDashContentTab('videos', document.querySelector('#dash-content-subtabs .ch-tab[data-cdtab="videos"]')); _call('loadDashAiMomentsCount'); };
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
// moderation loader is registered by dashboard-moderation.js
@@ -274,6 +274,29 @@ function showEndpointInfo(stream) {
@@ -274,6 +274,29 @@ function showEndpointInfo(stream) {
el.textContent = info;
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 ─────────────────────────────────────────────────────── */
/* ── Goals ─────────────────────────────────────────────────────── */
async function loadDashGoals() {
async function loadDashGoals() {
if (!currentUser) return;
if (!currentUser) return;
@@ -1,13 +1,28 @@
@@ -1,13 +1,28 @@
'use strict';
'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 {})
* 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() } = {}) {
function createSummaryCache(getStats, { ttlMs = 600_000, now = () => Date.now() } = {}) {
const entries = new Map(); // days -> { at, promise }
const entries = new Map(); // days -> { at, promise }
return function summary(days) {
return function summary(days) {
@@ -22,4 +37,4 @@ function createSummaryCache(getStats, { ttlMs = 600_000, now = () => Date.now()
@@ -22,4 +37,4 @@ function createSummaryCache(getStats, { ttlMs = 600_000, now = () => Date.now()
};
};
}
}
-module.exports = { createSummaryCache };
+module.exports = { createSummaryCache, rollupSummary };
@@ -709,8 +709,9 @@ app.post('/internal/openre-events', require('./openre/mirror').webhookHandler);
@@ -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
// 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).
// 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.
// 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) => {
app.get('/internal/analytics-summary', require('./net/service-guard').guard('live.analytics.read'), async (req, res) => {
try {
try {
const days = Math.min(parseInt(req.query.days, 10) || 7, 90);
const days = Math.min(parseInt(req.query.days, 10) || 7, 90);
@@ -2,9 +2,10 @@
@@ -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
* 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
* 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
* 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.
* (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) {
@@ -25,14 +26,14 @@ function minutes(seconds) {
}
}
/** The notification for a first stream: { title, message, url }. */
/** 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 channel = `${LIVE_URL}/@${encodeURIComponent(username)}`;
const where = vodUrl ? 'The recording is on OpenVibe.Video, ready to watch, clip and share.'
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.'
: 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.';
: 'The recording appears on your channel once it is ready.';
return {
return {
title: 'Your first stream is done 🎉',
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,
url: vodUrl || null,
};
};
}
}
@@ -58,7 +59,8 @@ async function run({ now = Date.now, push = null, vodFor = null } = {}) {
@@ -58,7 +59,8 @@ async function run({ now = Date.now, push = null, vodFor = null } = {}) {
if (seconds < MIN_SECONDS) continue;
if (seconds < MIN_SECONDS) continue;
let vodUrl = null;
let vodUrl = null;
try { vodUrl = await findVod(s); } catch { /* Media unreachable: the channel link instead */ }
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)}`);
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 });
send({ user_id: s.user_id, type: 'STREAM_SUMMARY', category: 'stream', title: m.title, message: m.message, url });
sent++;
sent++;
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -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; });
@@ -1,7 +1,8 @@
@@ -1,7 +1,8 @@
'use strict';
'use strict';
// The first stream's follow-up (server/streaming/first-stream-followup.js): one notification after someone's first
// 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
// 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 assert = require('assert');
const db = require('../server/db/database');
const db = require('../server/db/database');
const followup = require('../server/streaming/first-stream-followup');
const followup = require('../server/streaming/first-stream-followup');
@@ -32,6 +33,12 @@ const sqlTime = (ms) => new Date(ms).toISOString().replace('T', ' ').slice(0, 19
@@ -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: 5000, seconds: 900 });
await mkStream(ed, { endedAgoMin: 20, 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 sent = [];
const vodFor = async (s) => (s.id === anaStream ? 'https://openvibe.video/v/77' : null);
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 });
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
@@ -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]));
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.deepStrictEqual(Object.keys(byUser).map(Number).sort(), [ana, bo].map(Number).sort());
assert.strictEqual(byUser[ana].url, 'https://openvibe.video/v/77');
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.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[bo].url, `https://openvibe.live/recap/${boStream}`, 'no public recording yet: the after-show report');
assert.strictEqual(byUser[ana].type, 'STREAM_SUMMARY');
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.
// 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);
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
@@ -57,6 +64,7 @@ const sqlTime = (ms) => new Date(ms).toISOString().replace('T', ' ').slice(0, 19
// Short first stream with no recording: the channel.
// Short first stream with no recording: the channel.
const m = followup.messageFor({ username: 'zed', seconds: 90, vodUrl: null });
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(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');
console.log('first-stream follow-up: once, settled, recording/report/channel links, no test streams');
process.exit(0);
process.exit(0);
})().catch((e) => { console.error(e); process.exit(1); });
})().catch((e) => { console.error(e); process.exit(1); });