openvibers/openvibe.report Commits
Report: rank the front door's popular destinations (L43) (#160)
Changes
@@@ -103,10 +103,12 @@ HTML is `no-store` so the outage notice stays current.## API--All under `/api/v1`, CORS `*`, `Cache-Control: public, max-age=120`, 120 requests a minute per address:++All under `/api/v1`, CORS `*`, `Cache-Control: public, max-age=120` (the popularity pair: 300), 120 requests a minute per address:```GET /api/v1/stats?days=7|30|90 REPORT_SOURCE=network|report++GET /api/v1/popular?limit=5 the front door's ranked sites and tools (public, aggregate)++GET /api/v1/popular/co-usage opt-in site pairs for "Try next", small groups omittedGET /api/v1/rundownGET /api/v1/rundown/topicsGET /api/v1/rundown/t/:slug@@@ -124,6 +126,12 @@ methods get 405. `GET /api/v1/me/stats` and `PUT /api/v1/me/stats/published` ans(the switch request is `report.me-stats-publish@1`); the public route copies only the published sections from theperson's last snapshot, so a section a source did not answer for is absent, never a zero.++`/api/v1/popular` ranks Report's own daily and window numbers: opens today (UTC, still counting) and over seven days,++the seven before that for growth, and the distinct signed-in people Network's cross-site history counts. A tool is a++destination beside a site. The answer is public and aggregate only, held five minutes. `/api/v1/popular/co-usage`++reads only people who switched their `sites` section public (`report_me_stats.published`); it returns pair counts, never++a person, username or subject, and a pair below three people is left out.++The AI Exchange JSON read is the staff exception (with `REPORT_AI_EXCHANGE=1`): a Network service token with thesame `report.ai_usage.read` capability the AI meter uses, never cached, no person or project in the answer. TheHTML page is gated by the signed-in person's staff role (`staff.site.view`) instead, like the other staff pages.@@@ -136,6 +136,18 @@ function metricRowsFromStats(stats) {win('traffic_server_errors', 'site', id, t.server_errors);win('traffic_p95_ms', 'site', id, t.p95_ms);}++ // The rolling numbers Report's popularity reads: page views today and over seven days, and the distinct++ // signed-in people Network counted for the site (history only; guests are never in it).++ for (const k of ['opens_24h', 'opens_7d', 'opens_prev_7d', 'uses_24h', 'uses_7d', 'uses_prev_7d', 'growth_7d_pct']) win(k, 'site', id, site[k]);++ }++ // Tools are destinations of their own (a tool host's page views, not only the Tools site's total): one row per++ // tool, with the same rolling metrics, so the home's list can rank a tool beside a site.++ for (const tool of Array.isArray(s.tools) ? s.tools : []) {++ if (!tool || typeof tool !== 'object') continue;++ const id = tool.id || tool.host || tool.short;++ if (!id) continue;++ for (const k of ['opens_24h', 'opens_7d', 'opens_prev_7d', 'uses_24h', 'uses_7d', 'uses_prev_7d', 'growth_7d_pct', 'change_pct']) win(k, 'tool', id, tool[k]);++ for (const p of seriesOf(tool.daily)) day('pageviews', 'tool', id, p.day, p.n);}return [...seen.values()];}@@@ -175,6 +187,18 @@ function dimensionsFromStats(stats) {},});}++ for (const tool of Array.isArray(s.tools) ? s.tools : []) {++ if (!tool || typeof tool !== 'object') continue;++ const id = tool.id || tool.host || tool.short;++ if (!id) continue;++ docs.push({++ kind: 'tool', id,++ doc: {++ id, name: tool.name || tool.short || id, short: tool.short || id, host: tool.host || null,++ url: tool.url || (tool.host ? `https://${tool.host}/` : null), tagline: tool.tagline || '', icon: tool.icon || id,++ },++ });++ }return docs;}@@@ -3,7 +3,7 @@/*** Report's read side: the same numbers Network answers, computed from Report's own tables.*-- * reportStats(days) GET /api/v1/stats' shape: network totals, sites, accounts, OpenCoins, Live and day series++ * reportStats(days) GET /api/v1/stats' shape: network totals, sites, tools, accounts, OpenCoins, Live and day series* channelStats(channel) creator analytics from the live.stream.ended events, plus Billing's revenue read* metrics(scope,id) the flat report.metric@1 rows the shadow job and operators inspect*@@@ -105,6 +105,30 @@ async function reportStats(store, days = 30, { now = () => Date.now() } = {}) {for (const r of idx.rows) if (r.scope === 'site' && r.day >= w.currentStart) siteIds.add(r.scope_id);for (const r of idx.winRows) if (r.scope === 'site') siteIds.add(r.scope_id);for (const d of idx.dims) if (d.kind === 'site') siteIds.add(d.id);++ const toolIds = new Set();++ for (const r of idx.rows) if (r.scope === 'tool' && r.day >= w.currentStart) toolIds.add(r.scope_id);++ for (const r of idx.winRows) if (r.scope === 'tool') toolIds.add(r.scope_id);++ for (const d of idx.dims) if (d.kind === 'tool') toolIds.add(d.id);++++ // The rolling windows: today (UTC, still counting) and the last seven finished days against the seven before.++ const sevenFrom = w.days[Math.max(0, w.days.length - 7)];++ const prevSevenFrom = w.days.length >= 14 ? w.days[w.days.length - 14] : null;++ const prevSevenTo = w.days.length >= 8 ? w.days[w.days.length - 8] : null;++ const today = w.currentEnd;++ const windowFields = (idx, scope, id) => {++ const opens24 = winGet(idx, 'opens_24h', scope, id) ?? dayGet(idx, 'pageviews', scope, id, today);++ const opens7 = winGet(idx, 'opens_7d', scope, id) ?? daySum(idx, 'pageviews', scope, id, sevenFrom, w.currentEnd);++ const opensPrev7 = winGet(idx, 'opens_prev_7d', scope, id)++ ?? (prevSevenFrom && prevSevenTo ? daySum(idx, 'pageviews', scope, id, prevSevenFrom, prevSevenTo) : null);++ const uses24 = winGet(idx, 'uses_24h', scope, id);++ const uses7 = winGet(idx, 'uses_7d', scope, id);++ const usesPrev7 = winGet(idx, 'uses_prev_7d', scope, id);++ const growth = winGet(idx, 'growth_7d_pct', scope, id) ?? change(opens7, opensPrev7);++ return {++ opens_24h: opens24, opens_7d: opens7, opens_prev_7d: opensPrev7,++ uses_24h: uses24, uses_7d: uses7, uses_prev_7d: usesPrev7, growth_7d_pct: growth,++ };++ };const sites = [];for (const id of siteIds) {@@@ -121,12 +145,14 @@ async function reportStats(store, days = 30, { now = () => Date.now() } = {}) {server_errors: winGet(idx, 'traffic_server_errors', 'site', id),p95_ms: winGet(idx, 'traffic_p95_ms', 'site', id),} : null;++ const rolling = windowFields(idx, 'site', id);sites.push({...meta,pageviews: pageviews == null ? null : pageviews,pageviews_prev: prev == null ? null : prev,change_pct: pct,daily: hasDaily ? daySeries(idx, 'pageviews', 'site', id, w.days) : null,++ ...Object.fromEntries(Object.entries(rolling).filter(([, v]) => v != null)),...(winGet(idx, 'unique_visitors', 'site', id) != null ? { unique_visitors: winGet(idx, 'unique_visitors', 'site', id) } : {}),...(winGet(idx, 'signed_in_people', 'site', id) != null ? { signed_in_people: winGet(idx, 'signed_in_people', 'site', id) } : {}),traffic_24h: traffic,@@@ -134,6 +160,21 @@ async function reportStats(store, days = 30, { now = () => Date.now() } = {}) {}sites.sort((a, b) => (b.pageviews || 0) - (a.pageviews || 0) || String(a.name).localeCompare(String(b.name)));++ const tools = [];++ for (const id of toolIds) {++ const meta = dimOf('tool', id) || { id, name: id, short: id };++ const hasDaily = dayHas(idx, 'pageviews', 'tool', id);++ const hasAny = hasDaily || winHas(idx, 'tool', id);++ if (!hasAny) continue;++ const rolling = windowFields(idx, 'tool', id);++ tools.push({++ ...meta,++ ...Object.fromEntries(Object.entries(rolling).filter(([, v]) => v != null)),++ daily: hasDaily ? daySeries(idx, 'pageviews', 'tool', id, w.days) : null,++ });++ }++ tools.sort((a, b) => (b.opens_7d || 0) - (a.opens_7d || 0) || String(a.name).localeCompare(String(b.name)));++const networkHasDaily = dayHas(idx, 'pageviews', 'network', 'network');let pageviews = winGet(idx, 'pageviews', 'network', 'network') ?? daySum(idx, 'pageviews', 'network', 'network', w.currentStart, w.currentEnd);if (pageviews == null) pageviews = sumOrNull(sites.map((s) => s.pageviews));@@@ -195,6 +236,7 @@ async function reportStats(store, days = 30, { now = () => Date.now() } = {}) {...(hasLive ? { live } : {}),busiest_pages_24h: idx.json.get('network|network|busiest_pages') || null,sites,++ ...(tools.length ? { tools } : {}),};}@@@ -42,6 +42,7 @@ const createAiExchangeConfig = require('./ai-exchange-config').create;const { createSearchIndex } = require('./search-index');const { createSessionReports } = require('./session-reports');const { createMeStats } = require('./me-stats');++const { createPopularity } = require('./popularity');const { createSessionReportRoutes } = require('./http/session-reports');const sharing = require('./sharing');const { createShadow } = require('./analytics/shadow');@@@ -110,6 +111,8 @@ async function createApp(opts = {}) {// snapshot is stored here; the public page reads only what the person published. With no store, /me computes but// nothing can be published.const meStats = opts.meStats || createMeStats({ store, network, analytics, aiUsage, sessionReports, config, now, log });++ // The front door's and "Try next"'s numbers: Report's own tables, five-minute memo, aggregate only.++ const popularity = opts.popularity || createPopularity({ store, now, log });// The sharing system (openvibe-sdk/share, share.v1): a report's four audiences live in its share row, the owner's// Share sheet changes it, and every read path asks canView (server/sharing.js). Built before the session-report// writer, so the first write registers its row.@@@ -126,7 +129,7 @@ async function createApp(opts = {}) {secureCookies: /^https:/.test(config.baseUrl), fetch: fetchImpl, log,});-- const ctx = { config, log, fetchImpl, now, store, network, live, billing, bench, aiCatalogue, analytics, shadow, sso, aiUsage, aiExchange, searchIndex, exchangeConfig, sessionReports, meStats };++ const ctx = { config, log, fetchImpl, now, store, network, live, billing, bench, aiCatalogue, analytics, shadow, sso, aiUsage, aiExchange, searchIndex, exchangeConfig, sessionReports, meStats, popularity };const app = express();if (exchangeConfig) exchangeConfig.mount(app);// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts@@@ -319,7 +322,7 @@ async function createApp(opts = {}) {legacyHeaders: false,handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }),}));-- app.use('/api/v1', createApi({ network, live, bench, aiCatalogue, analytics, aiUsage, aiExchange, sessionReports, meStats, config, now, log }));++ app.use('/api/v1', createApi({ network, live, bench, aiCatalogue, analytics, aiUsage, aiExchange, sessionReports, meStats, popularity, config, now, log }));app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));// ── Crawl artifacts, then the pages ─────────────────────@@@ -32,7 +32,7 @@ const sharing = require('../sharing');const CACHE = 'public, max-age=120';--function createApi({ network, live, bench = null, aiCatalogue = null, analytics = null, aiUsage = null, aiExchange = null, sessionReports = null, meStats = null, config = {}, now = () => Date.now(), log = console }) {++function createApi({ network, live, bench = null, aiCatalogue = null, analytics = null, aiUsage = null, aiExchange = null, sessionReports = null, meStats = null, popularity = null, config = {}, now = () => Date.now(), log = console }) {const r = express.Router();const serviceAuth = createServiceAuth({ config, now });@@@ -98,6 +98,24 @@ function createApi({ network, live, bench = null, aiCatalogue = null, analyticsreturn res.json(stats);});++ // The front door's five things people do now (plan L43): ranked from Report's own tables. Public and aggregate++ // only; a tool is a destination beside a site. Five-minute memo, so this answer is cheap for every reader.++ r.get('/popular', async (req, res) => {++ if (!popularity) return problem(req, res, 503, 'report.no_database', 'Report has no database in this process.');++ const body = await popularity.popular({ limit: req.query.limit });++ if (!body) return notFound(req, res, 'report.popular_not_found', 'The popularity numbers did not answer.');++ return res.set('Cache-Control', 'public, max-age=300').json(body);++ });++++ // Which sites people who publish their site list use together (the opt-in rule in server/me-stats.js). Pair++ // counts only, and a pair below the small-group floor is left out; no person, username or subject is in it.++ r.get('/popular/co-usage', async (req, res) => {++ if (!popularity) return problem(req, res, 503, 'report.no_database', 'Report has no database in this process.');++ const body = await popularity.coUsage();++ if (!body) return notFound(req, res, 'report.popular_not_found', 'The co-usage numbers did not answer.');++ return res.set('Cache-Control', 'public, max-age=300').json(body);++ });++// Every site's design health (plan L45 step 6): the snapshot scripts/design-health.js wrote from each site's code.r.get('/design-health', (req, res) => res.json(designHealth.all()));nnew file mode 100644@@@ -0,0 +1,179 @@++'use strict';++++/**++ * What people do now, for the front door and a person's "Try next" (plan L43, 2026-11-09).++ *++ * popular() the ranked destinations (sites and tools) from Report's own numbers: opens today and over seven++ * days, the seven before that for growth, and the distinct signed-in people Network counted. The++ * answer never names a person: it is counts and public labels only.++ * coUsage() which sites appear together in the site lists people chose to publish (report_me_stats.published++ * sites is the opt-in; a row whose switch is false is never read). Aggregate pair counts only, and a++ * pair is left out below MIN_PEOPLE so a small group cannot be traced back to a person.++ *++ * Both are held for five minutes: the numbers change when Network's five-minute stats cache changes, not sooner.++ * A table that has no rows is an empty answer, never a made-up zero; the caller falls back to its own fixed list.++ */++const { reportStats } = require('./analytics/query');++++const TTL_MS = 5 * 60 * 1000;++const MIN_PEOPLE = 3;++const MAX_PAIRS = 500;++const MAX_LIMIT = 20;++++const n = (v) => (v == null || v === '' || !Number.isFinite(Number(v)) ? null : Number(v));++const clampLimit = (v, fallback = 5) => Math.min(MAX_LIMIT, Math.max(1, parseInt(v, 10) || fallback));++const change = (now, before) => (before > 0 && now != null ? Math.round(((now - before) / before) * 100) : null);++const words = (v, max) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, max);++const httpsUrl = (v) => {++ try { const u = new URL(String(v || '')); return u.protocol === 'https:' ? u.href : null; } catch { return null; }++};++const idOf = (v) => (/^[a-z0-9][a-z0-9-]{0,39}$/.test(String(v || '')) ? String(v) : null);++++function lastDaily(daily) {++ return Array.isArray(daily) && daily.length ? n(daily[daily.length - 1].n) : null;++}++++/** One destination in the shape both lists use: a label, a URL, an icon id, a reason, and the numbers behind them. */++function destination(kind, row) {++ const id = idOf(row && row.id);++ const url = httpsUrl(row && (row.url || (row.host ? `https://${row.host}/` : null)));++ const name = words(row && (row.name || row.short || row.id), 80);++ if (!id || !url || !name) return null;++ const opens24 = n(row.opens_24h) ?? lastDaily(row.daily);++ const opens7 = n(row.opens_7d) ?? n(row.pageviews);++ const opensPrev7 = n(row.opens_prev_7d) ?? n(row.pageviews_prev);++ const uses24 = n(row.uses_24h);++ const uses7 = n(row.uses_7d);++ const growth = n(row.growth_7d_pct) ?? n(row.change_pct) ?? change(opens7, opensPrev7);++ // Page views lead; a person is worth five opens, so a small, clearly-used tool is not buried under one huge site.++ const score = (opens7 || 0) + (uses7 || 0) * 5;++ if (score <= 0) return null;++ return {++ id, kind, name, url,++ icon: idOf(row.icon) || id,++ score: Math.round(score),++ opens_24h: opens24, opens_7d: opens7, opens_prev_7d: opensPrev7,++ growth_7d_pct: growth, uses_24h: uses24, uses_7d: uses7,++ };++}++++function reasonOf(item, topToday) {++ if (item.opens_24h != null && item.opens_24h > 0 && item.opens_24h === topToday) return 'Most used today';++ if (item.growth_7d_pct != null && item.growth_7d_pct >= 10 && (item.opens_7d || 0) > 0) return 'Rising this week';++ return 'Popular this week';++}++++function createPopularity({ store = null, now = () => Date.now(), ttlMs = TTL_MS, minPeople = MIN_PEOPLE, statsOf = null, coRowsOf = null, log = console } = {}) {++ const db = store && store.db ? store.db : null;++ const clock = typeof now === 'function' ? now : () => Date.now();++ const stats = statsOf || ((days) => reportStats(store, days, { now: clock }));++ const coRows = coRowsOf || (async () => {++ if (!db) return null;++ return db.many(`SELECT snapshot FROM report_me_stats++ WHERE COALESCE(published->>'sites', 'false') = 'true' AND snapshot IS NOT NULL++ ORDER BY updated_at DESC LIMIT 5000`);++ });++ const cache = new Map();++ const inflight = new Map();++++ function warn(what, err) {++ try { (log.warn || log.error || console.warn).call(log, `[OpenVibe.Report] ${what} failed: ${String((err && err.message) || err).replace(/\s+/g, ' ').slice(0, 200)}`); } catch { /* logging never fails an answer */ }++ }++++ /** Five-minute memo: a promise is shared while it runs; a failure is not cached. */++ function memo(key, fn) {++ const hit = cache.get(key);++ if (hit && clock() - hit.at < ttlMs) return Promise.resolve(hit.value);++ if (inflight.has(key)) return inflight.get(key);++ const p = Promise.resolve().then(fn).then((value) => {++ cache.set(key, { at: clock(), value });++ return value;++ }).finally(() => inflight.delete(key));++ inflight.set(key, p);++ return p;++ }++++ async function buildPopular() {++ if (!db && !statsOf) return null;++ let body;++ try { body = await stats(7); } catch (err) { warn('popular stats', err); return null; }++ if (!body || typeof body !== 'object') return null;++ const candidates = [];++ for (const site of Array.isArray(body.sites) ? body.sites : []) {++ if (!site || site.id === 'network') continue; // the account desk is not a destination++ const item = destination('site', site);++ if (item) candidates.push(item);++ }++ for (const tool of Array.isArray(body.tools) ? body.tools : []) {++ const item = destination('tool', tool);++ if (item) candidates.push(item);++ }++ candidates.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));++ const top = candidates.slice(0, 50);++ const topToday = top.reduce((best, item) => (item.opens_24h != null && item.opens_24h > best ? item.opens_24h : best), 0);++ const generatedAt = body.generated_at || new Date(clock()).toISOString();++ return {++ source: 'report',++ generated_at: generatedAt,++ window: {++ days: 7,++ today: new Date(clock()).toISOString().slice(0, 10),++ opens_24h: 'page views counted since 00:00 UTC today',++ uses_24h: 'distinct signed-in people in the last 24 hours',++ opens_7d: 'page views over the last seven days',++ },++ items: top.map((item) => ({ ...item, reason: reasonOf(item, topToday) })),++ };++ }++++ async function popular({ limit = 5 } = {}) {++ const full = await memo('popular', buildPopular);++ if (!full) return null;++ return { ...full, items: full.items.slice(0, clampLimit(limit)) };++ }++++ async function buildCoUsage() {++ if (!db && !coRowsOf) return null;++ const rows = await coRows();++ if (rows == null) return null;++ const counts = new Map();++ let sample = 0;++ for (const row of rows) {++ let snapshot = row && row.snapshot;++ if (typeof snapshot === 'string') { try { snapshot = JSON.parse(snapshot); } catch { snapshot = null; } }++ const month = snapshot && snapshot.sites && Array.isArray(snapshot.sites.month) ? snapshot.sites.month : [];++ const services = new Set();++ for (const entry of month) {++ const service = String((entry && entry.service) || '').trim().toLowerCase();++ if (/^[a-z0-9-]{1,32}$/.test(service) && service !== 'network') services.add(service);++ }++ if (services.size < 2) continue;++ sample += 1;++ const list = [...services].sort();++ for (let i = 0; i < list.length; i++) {++ for (let j = i + 1; j < list.length; j++) {++ const key = `${list[i]}|${list[j]}`;++ counts.set(key, (counts.get(key) || 0) + 1);++ }++ }++ }++ const pairs = [...counts.entries()]++ .map(([key, people]) => { const [a, b] = key.split('|'); return { a, b, people }; })++ .filter((p) => p.people >= minPeople)++ .sort((a, b) => b.people - a.people || a.a.localeCompare(b.a) || a.b.localeCompare(b.b))++ .slice(0, MAX_PAIRS);++ return {++ source: 'report',++ generated_at: new Date(clock()).toISOString(),++ sample_people: sample,++ min_people: minPeople,++ pairs,++ };++ }++++ function coUsage() { return memo('co-usage', buildCoUsage); }++++ return { popular, coUsage, ttlMs, minPeople, _clear() { cache.clear(); } };++}++++module.exports = { createPopularity, TTL_MS, MIN_PEOPLE, destination, reasonOf };@@@ -48,6 +48,14 @@ const { boot, check, done, defaults } = require('./helpers/boot');assert.strictEqual(t.fetchImpl.calls.length, before, 'a bad parameter reached the source');});++ await check('the popularity routes answer problem+json when Report has no numbers', async () => {++ for (const path of ['/api/v1/popular', '/api/v1/popular/co-usage']) {++ const r = await t.get(path);++ assert.strictEqual(r.status, 404, path);++ assert.match(r.headers.get('content-type') || '', /^application\/problem\+json/);++ }++ });++await check('the API is read-only', async () => {const r = await t.get('/api/v1/stats', { method: 'POST' });assert.strictEqual(r.status, 405);nnew file mode 100644@@@ -0,0 +1,117 @@++'use strict';++++/**++ * The front door's popularity read and "Try next"'s co-usage (plan L43): Report's own tables answer the ranked++ * destinations, a tool is a destination beside a site, the co-usage answer only reads people who published their++ * site list, and both are held five minutes. A pair below the small-group floor is never returned.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { createPopularity, TTL_MS, MIN_PEOPLE } = require('../server/popularity');++++const DAY = 86_400_000;++const NOW = Date.parse('2026-11-09T12:00:00Z');++const day = (back) => new Date(NOW - back * DAY).toISOString().slice(0, 10);++const series = (values) => values.map((n, i) => ({ day: day(values.length - 1 - i), n }));++++function statsFixture() {++ return {++ generated_at: new Date(NOW).toISOString(),++ window_days: 7,++ sites: [++ { id: 'live', name: 'OpenVibe.Live', short: 'Live', host: 'openvibe.live', url: 'https://openvibe.live/', icon: 'live', pageviews: 700, pageviews_prev: 500, change_pct: 40, opens_24h: 100, opens_7d: 700, opens_prev_7d: 500, uses_24h: 12, uses_7d: 40, daily: series([100, 100, 100, 100, 100, 100, 100]) },++ { id: 'tools', name: 'OpenVibe.Tools', short: 'Tools', host: 'openvibe.tools', url: 'https://openvibe.tools/', icon: 'tools', pageviews: 500, pageviews_prev: 400, change_pct: 25, opens_24h: 80, opens_7d: 500, opens_prev_7d: 400, uses_24h: 8, uses_7d: 20, daily: series([70, 70, 70, 70, 70, 75, 75]) },++ { id: 'chat', name: 'OpenVibe.Chat', short: 'Chat', host: 'openvibe.chat', url: 'https://openvibe.chat/', icon: 'chat', pageviews: 100, pageviews_prev: 110, change_pct: -9, opens_24h: 10, opens_7d: 100, opens_prev_7d: 110, daily: series([15, 15, 15, 15, 15, 15, 10]) },++ { id: 'network', name: 'OpenVibe.Network', short: 'Network', host: 'openvibe.network', url: 'https://openvibe.network/', icon: 'network', pageviews: 9999, change_pct: 999 },++ ],++ tools: [++ { id: 'yt', name: 'YouTube downloader', short: 'yt', host: 'yt.openvibe.tools', url: 'https://yt.openvibe.tools/', icon: 'yt', opens_24h: 200, opens_7d: 900, opens_prev_7d: 300, uses_24h: 4, uses_7d: 11, daily: series([100, 100, 100, 100, 100, 200, 200]) },++ { id: 'dns', name: 'DNS lookup', short: 'dns', host: 'dns.openvibe.tools', url: 'https://dns.openvibe.tools/', icon: 'dns', opens_24h: 20, opens_7d: 150, opens_prev_7d: 100, daily: series([20, 20, 20, 20, 25, 25, 20]) },++ ],++ };++}++++(async () => {++ await check('the ranked list is sites and tools from Report\'s numbers, with a reason and the icon id', async () => {++ const t = await boot({ db: true, now: () => NOW });++ try {++ const ingest = await t.ctx.analytics.ingestStats(statsFixture(), { now: () => NOW });++ assert.ok(ingest.ok, 'the stats fixture is ingested');++ const stats = await t.ctx.analytics.reportStats(7);++ assert.deepStrictEqual(stats.tools.map((tool) => tool.id), ['yt', 'dns'], 'tools are in the stats shape, ranked');++ assert.strictEqual(stats.tools[0].opens_7d, 900);++ assert.strictEqual(stats.sites.find((s) => s.id === 'live').uses_7d, 40, 'the people count is kept');++ const body = await t.ctx.popularity.popular({ limit: 5 });++ assert.strictEqual(body.source, 'report');++ assert.ok(body.items.length >= 4, 'four destinations with numbers');++ assert.strictEqual(body.items[0].id, 'yt', 'the most opened destination leads');++ assert.strictEqual(body.items[0].kind, 'tool', 'a tool is a destination');++ assert.strictEqual(body.items[0].reason, 'Most used today');++ assert.ok(body.items.every((i) => i.url.startsWith('https://') && i.icon && i.reason && i.name), 'every item has a URL, icon id, reason and name');++ assert.ok(!body.items.some((i) => i.id === 'network'), 'the account desk is not a destination');++ const live = body.items.find((i) => i.id === 'live');++ assert.strictEqual(live.kind, 'site');++ assert.strictEqual(live.opens_7d, 700);++ assert.strictEqual(live.opens_24h, 100);++ assert.strictEqual(live.uses_7d, 40);++ assert.strictEqual(live.reason, 'Rising this week');++++ const api = await t.get('/api/v1/popular?limit=2');++ assert.strictEqual(api.status, 200);++ assert.strictEqual(api.headers.get('cache-control'), 'public, max-age=300', 'held five minutes');++ assert.strictEqual(api.headers.get('access-control-allow-origin'), '*');++ const json = api.json();++ assert.strictEqual(json.items.length, 2);++ assert.deepStrictEqual(json.items.map((i) => i.id), ['yt', 'live']);++ assert.ok(json.window && /00:00 UTC/.test(json.window.opens_24h));++ } finally { await t.close(); }++ });++++ await check('the co-usage list reads only published site lists, aggregates pairs, and hides a small group', async () => {++ const t = await boot({ db: true, now: () => NOW });++ try {++ const db = t.ctx.store.db;++ const insert = db.prepare(`INSERT INTO report_me_stats (subject, username, display_name, published, snapshot, snapshot_at, updated_at)++ VALUES (?, ?, ?, ?::jsonb, ?::jsonb, now(), now())`);++ const row = (id, month, published = true) => insert.run(`usr_${id}`, `person${id}`, `Person ${id}`,++ JSON.stringify({ sites: published }), JSON.stringify({ sites: { month: month.map((service) => ({ service, visits: 2 })) } }));++ await row('a', ['live', 'tools', 'chat']);++ await row('b', ['live', 'tools']);++ await row('c', ['live', 'tools', 'games']);++ await row('d', ['live', 'tools', 'chat']);++ await row('e', ['live', 'tools']);++ await row('f', ['live', 'tools'], false);++++ const body = await t.ctx.popularity.coUsage();++ assert.strictEqual(body.source, 'report');++ assert.strictEqual(body.sample_people, 5, 'the unpublished row is not read');++ assert.strictEqual(body.min_people, MIN_PEOPLE);++ assert.deepStrictEqual(body.pairs, [{ a: 'live', b: 'tools', people: 5 }], 'only a pair with enough people is returned');++ assert.ok(!JSON.stringify(body).includes('person'), 'no username or display name leaves the request');++ const r = await t.get('/api/v1/popular/co-usage');++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=300');++ assert.deepStrictEqual(r.json().pairs, body.pairs);++ } finally { await t.close(); }++ });++++ await check('both reads are memoized for five minutes', async () => {++ let at = NOW;++ let calls = 0;++ const pop = createPopularity({ now: () => at, statsOf: async () => { calls++; return statsFixture(); } });++ await pop.popular({ limit: 5 });++ await pop.popular({ limit: 5 });++ assert.strictEqual(calls, 1, 'the second read inside five minutes is the memo');++ at += TTL_MS;++ await pop.popular({ limit: 5 });++ assert.strictEqual(calls, 2, 'after five minutes it is read again');++ });++++ await check('a stats answer with no destinations is an empty list, never a made-up one', async () => {++ const pop = createPopularity({ now: () => NOW, statsOf: async () => ({ generated_at: new Date(NOW).toISOString(), window_days: 7, sites: [], tools: [] }) });++ const body = await pop.popular();++ assert.deepStrictEqual(body.items, []);++ });++++ done();++})().catch((e) => { console.error(e); process.exit(1); });
@@ -103,10 +103,12 @@ HTML is `no-store` so the outage notice stays current.
@@ -103,10 +103,12 @@ HTML is `no-store` so the outage notice stays current.
## API
## API
-All under `/api/v1`, CORS `*`, `Cache-Control: public, max-age=120`, 120 requests a minute per address:
+All under `/api/v1`, CORS `*`, `Cache-Control: public, max-age=120` (the popularity pair: 300), 120 requests a minute per address:
```
```
GET /api/v1/stats?days=7|30|90 REPORT_SOURCE=network|report
GET /api/v1/stats?days=7|30|90 REPORT_SOURCE=network|report
+GET /api/v1/popular?limit=5 the front door's ranked sites and tools (public, aggregate)
+GET /api/v1/popular/co-usage opt-in site pairs for "Try next", small groups omitted
GET /api/v1/rundown
GET /api/v1/rundown
GET /api/v1/rundown/topics
GET /api/v1/rundown/topics
GET /api/v1/rundown/t/:slug
GET /api/v1/rundown/t/:slug
@@ -124,6 +126,12 @@ methods get 405. `GET /api/v1/me/stats` and `PUT /api/v1/me/stats/published` ans
@@ -124,6 +126,12 @@ methods get 405. `GET /api/v1/me/stats` and `PUT /api/v1/me/stats/published` ans
(the switch request is `report.me-stats-publish@1`); the public route copies only the published sections from the
(the switch request is `report.me-stats-publish@1`); the public route copies only the published sections from the
person's last snapshot, so a section a source did not answer for is absent, never a zero.
person's last snapshot, so a section a source did not answer for is absent, never a zero.
+`/api/v1/popular` ranks Report's own daily and window numbers: opens today (UTC, still counting) and over seven days,
+the seven before that for growth, and the distinct signed-in people Network's cross-site history counts. A tool is a
+destination beside a site. The answer is public and aggregate only, held five minutes. `/api/v1/popular/co-usage`
+reads only people who switched their `sites` section public (`report_me_stats.published`); it returns pair counts, never
+a person, username or subject, and a pair below three people is left out.
+
The AI Exchange JSON read is the staff exception (with `REPORT_AI_EXCHANGE=1`): a Network service token with the
The AI Exchange JSON read is the staff exception (with `REPORT_AI_EXCHANGE=1`): a Network service token with the
same `report.ai_usage.read` capability the AI meter uses, never cached, no person or project in the answer. The
same `report.ai_usage.read` capability the AI meter uses, never cached, no person or project in the answer. The
HTML page is gated by the signed-in person's staff role (`staff.site.view`) instead, like the other staff pages.
HTML page is gated by the signed-in person's staff role (`staff.site.view`) instead, like the other staff pages.
@@ -136,6 +136,18 @@ function metricRowsFromStats(stats) {
@@ -136,6 +136,18 @@ function metricRowsFromStats(stats) {
win('traffic_server_errors', 'site', id, t.server_errors);
win('traffic_server_errors', 'site', id, t.server_errors);
win('traffic_p95_ms', 'site', id, t.p95_ms);
win('traffic_p95_ms', 'site', id, t.p95_ms);
}
}
+ // The rolling numbers Report's popularity reads: page views today and over seven days, and the distinct
+ // signed-in people Network counted for the site (history only; guests are never in it).
+ for (const k of ['opens_24h', 'opens_7d', 'opens_prev_7d', 'uses_24h', 'uses_7d', 'uses_prev_7d', 'growth_7d_pct']) win(k, 'site', id, site[k]);
+ }
+ // Tools are destinations of their own (a tool host's page views, not only the Tools site's total): one row per
+ // tool, with the same rolling metrics, so the home's list can rank a tool beside a site.
+ for (const tool of Array.isArray(s.tools) ? s.tools : []) {
+ if (!tool || typeof tool !== 'object') continue;
+ const id = tool.id || tool.host || tool.short;
+ if (!id) continue;
+ for (const k of ['opens_24h', 'opens_7d', 'opens_prev_7d', 'uses_24h', 'uses_7d', 'uses_prev_7d', 'growth_7d_pct', 'change_pct']) win(k, 'tool', id, tool[k]);
+ for (const p of seriesOf(tool.daily)) day('pageviews', 'tool', id, p.day, p.n);
}
}
return [...seen.values()];
return [...seen.values()];
}
}
@@ -175,6 +187,18 @@ function dimensionsFromStats(stats) {
@@ -175,6 +187,18 @@ function dimensionsFromStats(stats) {
},
},
});
});
}
}
+ for (const tool of Array.isArray(s.tools) ? s.tools : []) {
+ if (!tool || typeof tool !== 'object') continue;
+ const id = tool.id || tool.host || tool.short;
+ if (!id) continue;
+ docs.push({
+ kind: 'tool', id,
+ doc: {
+ id, name: tool.name || tool.short || id, short: tool.short || id, host: tool.host || null,
+ url: tool.url || (tool.host ? `https://${tool.host}/` : null), tagline: tool.tagline || '', icon: tool.icon || id,
+ },
+ });
+ }
return docs;
return docs;
}
}
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
/**
/**
* Report's read side: the same numbers Network answers, computed from Report's own tables.
* Report's read side: the same numbers Network answers, computed from Report's own tables.
*
*
- * reportStats(days) GET /api/v1/stats' shape: network totals, sites, accounts, OpenCoins, Live and day series
+ * reportStats(days) GET /api/v1/stats' shape: network totals, sites, tools, accounts, OpenCoins, Live and day series
* channelStats(channel) creator analytics from the live.stream.ended events, plus Billing's revenue read
* channelStats(channel) creator analytics from the live.stream.ended events, plus Billing's revenue read
* metrics(scope,id) the flat report.metric@1 rows the shadow job and operators inspect
* metrics(scope,id) the flat report.metric@1 rows the shadow job and operators inspect
*
*
@@ -105,6 +105,30 @@ async function reportStats(store, days = 30, { now = () => Date.now() } = {}) {
@@ -105,6 +105,30 @@ async function reportStats(store, days = 30, { now = () => Date.now() } = {}) {
for (const r of idx.rows) if (r.scope === 'site' && r.day >= w.currentStart) siteIds.add(r.scope_id);
for (const r of idx.rows) if (r.scope === 'site' && r.day >= w.currentStart) siteIds.add(r.scope_id);
for (const r of idx.winRows) if (r.scope === 'site') siteIds.add(r.scope_id);
for (const r of idx.winRows) if (r.scope === 'site') siteIds.add(r.scope_id);
for (const d of idx.dims) if (d.kind === 'site') siteIds.add(d.id);
for (const d of idx.dims) if (d.kind === 'site') siteIds.add(d.id);
+ const toolIds = new Set();
+ for (const r of idx.rows) if (r.scope === 'tool' && r.day >= w.currentStart) toolIds.add(r.scope_id);
+ for (const r of idx.winRows) if (r.scope === 'tool') toolIds.add(r.scope_id);
+ for (const d of idx.dims) if (d.kind === 'tool') toolIds.add(d.id);
+
+ // The rolling windows: today (UTC, still counting) and the last seven finished days against the seven before.
+ const sevenFrom = w.days[Math.max(0, w.days.length - 7)];
+ const prevSevenFrom = w.days.length >= 14 ? w.days[w.days.length - 14] : null;
+ const prevSevenTo = w.days.length >= 8 ? w.days[w.days.length - 8] : null;
+ const today = w.currentEnd;
+ const windowFields = (idx, scope, id) => {
+ const opens24 = winGet(idx, 'opens_24h', scope, id) ?? dayGet(idx, 'pageviews', scope, id, today);
+ const opens7 = winGet(idx, 'opens_7d', scope, id) ?? daySum(idx, 'pageviews', scope, id, sevenFrom, w.currentEnd);
+ const opensPrev7 = winGet(idx, 'opens_prev_7d', scope, id)
+ ?? (prevSevenFrom && prevSevenTo ? daySum(idx, 'pageviews', scope, id, prevSevenFrom, prevSevenTo) : null);
+ const uses24 = winGet(idx, 'uses_24h', scope, id);
+ const uses7 = winGet(idx, 'uses_7d', scope, id);
+ const usesPrev7 = winGet(idx, 'uses_prev_7d', scope, id);
+ const growth = winGet(idx, 'growth_7d_pct', scope, id) ?? change(opens7, opensPrev7);
+ return {
+ opens_24h: opens24, opens_7d: opens7, opens_prev_7d: opensPrev7,
+ uses_24h: uses24, uses_7d: uses7, uses_prev_7d: usesPrev7, growth_7d_pct: growth,
+ };
+ };
const sites = [];
const sites = [];
for (const id of siteIds) {
for (const id of siteIds) {
@@ -121,12 +145,14 @@ async function reportStats(store, days = 30, { now = () => Date.now() } = {}) {
@@ -121,12 +145,14 @@ async function reportStats(store, days = 30, { now = () => Date.now() } = {}) {
server_errors: winGet(idx, 'traffic_server_errors', 'site', id),
server_errors: winGet(idx, 'traffic_server_errors', 'site', id),
p95_ms: winGet(idx, 'traffic_p95_ms', 'site', id),
p95_ms: winGet(idx, 'traffic_p95_ms', 'site', id),
} : null;
} : null;
+ const rolling = windowFields(idx, 'site', id);
sites.push({
sites.push({
...meta,
...meta,
pageviews: pageviews == null ? null : pageviews,
pageviews: pageviews == null ? null : pageviews,
pageviews_prev: prev == null ? null : prev,
pageviews_prev: prev == null ? null : prev,
change_pct: pct,
change_pct: pct,
daily: hasDaily ? daySeries(idx, 'pageviews', 'site', id, w.days) : null,
daily: hasDaily ? daySeries(idx, 'pageviews', 'site', id, w.days) : null,
+ ...Object.fromEntries(Object.entries(rolling).filter(([, v]) => v != null)),
...(winGet(idx, 'unique_visitors', 'site', id) != null ? { unique_visitors: winGet(idx, 'unique_visitors', 'site', id) } : {}),
...(winGet(idx, 'unique_visitors', 'site', id) != null ? { unique_visitors: winGet(idx, 'unique_visitors', 'site', id) } : {}),
...(winGet(idx, 'signed_in_people', 'site', id) != null ? { signed_in_people: winGet(idx, 'signed_in_people', 'site', id) } : {}),
...(winGet(idx, 'signed_in_people', 'site', id) != null ? { signed_in_people: winGet(idx, 'signed_in_people', 'site', id) } : {}),
traffic_24h: traffic,
traffic_24h: traffic,
@@ -134,6 +160,21 @@ async function reportStats(store, days = 30, { now = () => Date.now() } = {}) {
@@ -134,6 +160,21 @@ async function reportStats(store, days = 30, { now = () => Date.now() } = {}) {
}
}
sites.sort((a, b) => (b.pageviews || 0) - (a.pageviews || 0) || String(a.name).localeCompare(String(b.name)));
sites.sort((a, b) => (b.pageviews || 0) - (a.pageviews || 0) || String(a.name).localeCompare(String(b.name)));
+ const tools = [];
+ for (const id of toolIds) {
+ const meta = dimOf('tool', id) || { id, name: id, short: id };
+ const hasDaily = dayHas(idx, 'pageviews', 'tool', id);
+ const hasAny = hasDaily || winHas(idx, 'tool', id);
+ if (!hasAny) continue;
+ const rolling = windowFields(idx, 'tool', id);
+ tools.push({
+ ...meta,
+ ...Object.fromEntries(Object.entries(rolling).filter(([, v]) => v != null)),
+ daily: hasDaily ? daySeries(idx, 'pageviews', 'tool', id, w.days) : null,
+ });
+ }
+ tools.sort((a, b) => (b.opens_7d || 0) - (a.opens_7d || 0) || String(a.name).localeCompare(String(b.name)));
+
const networkHasDaily = dayHas(idx, 'pageviews', 'network', 'network');
const networkHasDaily = dayHas(idx, 'pageviews', 'network', 'network');
let pageviews = winGet(idx, 'pageviews', 'network', 'network') ?? daySum(idx, 'pageviews', 'network', 'network', w.currentStart, w.currentEnd);
let pageviews = winGet(idx, 'pageviews', 'network', 'network') ?? daySum(idx, 'pageviews', 'network', 'network', w.currentStart, w.currentEnd);
if (pageviews == null) pageviews = sumOrNull(sites.map((s) => s.pageviews));
if (pageviews == null) pageviews = sumOrNull(sites.map((s) => s.pageviews));
@@ -195,6 +236,7 @@ async function reportStats(store, days = 30, { now = () => Date.now() } = {}) {
@@ -195,6 +236,7 @@ async function reportStats(store, days = 30, { now = () => Date.now() } = {}) {
...(hasLive ? { live } : {}),
...(hasLive ? { live } : {}),
busiest_pages_24h: idx.json.get('network|network|busiest_pages') || null,
busiest_pages_24h: idx.json.get('network|network|busiest_pages') || null,
sites,
sites,
+ ...(tools.length ? { tools } : {}),
};
};
}
}
@@ -42,6 +42,7 @@ const createAiExchangeConfig = require('./ai-exchange-config').create;
@@ -42,6 +42,7 @@ const createAiExchangeConfig = require('./ai-exchange-config').create;
const { createSearchIndex } = require('./search-index');
const { createSearchIndex } = require('./search-index');
const { createSessionReports } = require('./session-reports');
const { createSessionReports } = require('./session-reports');
const { createMeStats } = require('./me-stats');
const { createMeStats } = require('./me-stats');
+const { createPopularity } = require('./popularity');
const { createSessionReportRoutes } = require('./http/session-reports');
const { createSessionReportRoutes } = require('./http/session-reports');
const sharing = require('./sharing');
const sharing = require('./sharing');
const { createShadow } = require('./analytics/shadow');
const { createShadow } = require('./analytics/shadow');
@@ -110,6 +111,8 @@ async function createApp(opts = {}) {
@@ -110,6 +111,8 @@ async function createApp(opts = {}) {
// snapshot is stored here; the public page reads only what the person published. With no store, /me computes but
// snapshot is stored here; the public page reads only what the person published. With no store, /me computes but
// nothing can be published.
// nothing can be published.
const meStats = opts.meStats || createMeStats({ store, network, analytics, aiUsage, sessionReports, config, now, log });
const meStats = opts.meStats || createMeStats({ store, network, analytics, aiUsage, sessionReports, config, now, log });
+ // The front door's and "Try next"'s numbers: Report's own tables, five-minute memo, aggregate only.
+ const popularity = opts.popularity || createPopularity({ store, now, log });
// The sharing system (openvibe-sdk/share, share.v1): a report's four audiences live in its share row, the owner's
// The sharing system (openvibe-sdk/share, share.v1): a report's four audiences live in its share row, the owner's
// Share sheet changes it, and every read path asks canView (server/sharing.js). Built before the session-report
// Share sheet changes it, and every read path asks canView (server/sharing.js). Built before the session-report
// writer, so the first write registers its row.
// writer, so the first write registers its row.
@@ -126,7 +129,7 @@ async function createApp(opts = {}) {
@@ -126,7 +129,7 @@ async function createApp(opts = {}) {
secureCookies: /^https:/.test(config.baseUrl), fetch: fetchImpl, log,
secureCookies: /^https:/.test(config.baseUrl), fetch: fetchImpl, log,
});
});
- const ctx = { config, log, fetchImpl, now, store, network, live, billing, bench, aiCatalogue, analytics, shadow, sso, aiUsage, aiExchange, searchIndex, exchangeConfig, sessionReports, meStats };
+ const ctx = { config, log, fetchImpl, now, store, network, live, billing, bench, aiCatalogue, analytics, shadow, sso, aiUsage, aiExchange, searchIndex, exchangeConfig, sessionReports, meStats, popularity };
const app = express();
const app = express();
if (exchangeConfig) exchangeConfig.mount(app);
if (exchangeConfig) exchangeConfig.mount(app);
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
@@ -319,7 +322,7 @@ async function createApp(opts = {}) {
@@ -319,7 +322,7 @@ async function createApp(opts = {}) {
legacyHeaders: false,
legacyHeaders: false,
handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }),
handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }),
}));
}));
- app.use('/api/v1', createApi({ network, live, bench, aiCatalogue, analytics, aiUsage, aiExchange, sessionReports, meStats, config, now, log }));
+ app.use('/api/v1', createApi({ network, live, bench, aiCatalogue, analytics, aiUsage, aiExchange, sessionReports, meStats, popularity, config, now, log }));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
// ── Crawl artifacts, then the pages ─────────────────────
// ── Crawl artifacts, then the pages ─────────────────────
@@ -32,7 +32,7 @@ const sharing = require('../sharing');
@@ -32,7 +32,7 @@ const sharing = require('../sharing');
const CACHE = 'public, max-age=120';
const CACHE = 'public, max-age=120';
-function createApi({ network, live, bench = null, aiCatalogue = null, analytics = null, aiUsage = null, aiExchange = null, sessionReports = null, meStats = null, config = {}, now = () => Date.now(), log = console }) {
+function createApi({ network, live, bench = null, aiCatalogue = null, analytics = null, aiUsage = null, aiExchange = null, sessionReports = null, meStats = null, popularity = null, config = {}, now = () => Date.now(), log = console }) {
const r = express.Router();
const r = express.Router();
const serviceAuth = createServiceAuth({ config, now });
const serviceAuth = createServiceAuth({ config, now });
@@ -98,6 +98,24 @@ function createApi({ network, live, bench = null, aiCatalogue = null, analytics
@@ -98,6 +98,24 @@ function createApi({ network, live, bench = null, aiCatalogue = null, analytics
return res.json(stats);
return res.json(stats);
});
});
+ // The front door's five things people do now (plan L43): ranked from Report's own tables. Public and aggregate
+ // only; a tool is a destination beside a site. Five-minute memo, so this answer is cheap for every reader.
+ r.get('/popular', async (req, res) => {
+ if (!popularity) return problem(req, res, 503, 'report.no_database', 'Report has no database in this process.');
+ const body = await popularity.popular({ limit: req.query.limit });
+ if (!body) return notFound(req, res, 'report.popular_not_found', 'The popularity numbers did not answer.');
+ return res.set('Cache-Control', 'public, max-age=300').json(body);
+ });
+
+ // Which sites people who publish their site list use together (the opt-in rule in server/me-stats.js). Pair
+ // counts only, and a pair below the small-group floor is left out; no person, username or subject is in it.
+ r.get('/popular/co-usage', async (req, res) => {
+ if (!popularity) return problem(req, res, 503, 'report.no_database', 'Report has no database in this process.');
+ const body = await popularity.coUsage();
+ if (!body) return notFound(req, res, 'report.popular_not_found', 'The co-usage numbers did not answer.');
+ return res.set('Cache-Control', 'public, max-age=300').json(body);
+ });
+
// Every site's design health (plan L45 step 6): the snapshot scripts/design-health.js wrote from each site's code.
// Every site's design health (plan L45 step 6): the snapshot scripts/design-health.js wrote from each site's code.
r.get('/design-health', (req, res) => res.json(designHealth.all()));
r.get('/design-health', (req, res) => res.json(designHealth.all()));
new file mode 100644
new file mode 100644
@@ -0,0 +1,179 @@
@@ -0,0 +1,179 @@
+'use strict';
+
+/**
+ * What people do now, for the front door and a person's "Try next" (plan L43, 2026-11-09).
+ *
+ * popular() the ranked destinations (sites and tools) from Report's own numbers: opens today and over seven
+ * days, the seven before that for growth, and the distinct signed-in people Network counted. The
+ * answer never names a person: it is counts and public labels only.
+ * coUsage() which sites appear together in the site lists people chose to publish (report_me_stats.published
+ * sites is the opt-in; a row whose switch is false is never read). Aggregate pair counts only, and a
+ * pair is left out below MIN_PEOPLE so a small group cannot be traced back to a person.
+ *
+ * Both are held for five minutes: the numbers change when Network's five-minute stats cache changes, not sooner.
+ * A table that has no rows is an empty answer, never a made-up zero; the caller falls back to its own fixed list.
+ */
+const { reportStats } = require('./analytics/query');
+
+const TTL_MS = 5 * 60 * 1000;
+const MIN_PEOPLE = 3;
+const MAX_PAIRS = 500;
+const MAX_LIMIT = 20;
+
+const n = (v) => (v == null || v === '' || !Number.isFinite(Number(v)) ? null : Number(v));
+const clampLimit = (v, fallback = 5) => Math.min(MAX_LIMIT, Math.max(1, parseInt(v, 10) || fallback));
+const change = (now, before) => (before > 0 && now != null ? Math.round(((now - before) / before) * 100) : null);
+const words = (v, max) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, max);
+const httpsUrl = (v) => {
+ try { const u = new URL(String(v || '')); return u.protocol === 'https:' ? u.href : null; } catch { return null; }
+};
+const idOf = (v) => (/^[a-z0-9][a-z0-9-]{0,39}$/.test(String(v || '')) ? String(v) : null);
+
+function lastDaily(daily) {
+ return Array.isArray(daily) && daily.length ? n(daily[daily.length - 1].n) : null;
+}
+
+/** One destination in the shape both lists use: a label, a URL, an icon id, a reason, and the numbers behind them. */
+function destination(kind, row) {
+ const id = idOf(row && row.id);
+ const url = httpsUrl(row && (row.url || (row.host ? `https://${row.host}/` : null)));
+ const name = words(row && (row.name || row.short || row.id), 80);
+ if (!id || !url || !name) return null;
+ const opens24 = n(row.opens_24h) ?? lastDaily(row.daily);
+ const opens7 = n(row.opens_7d) ?? n(row.pageviews);
+ const opensPrev7 = n(row.opens_prev_7d) ?? n(row.pageviews_prev);
+ const uses24 = n(row.uses_24h);
+ const uses7 = n(row.uses_7d);
+ const growth = n(row.growth_7d_pct) ?? n(row.change_pct) ?? change(opens7, opensPrev7);
+ // Page views lead; a person is worth five opens, so a small, clearly-used tool is not buried under one huge site.
+ const score = (opens7 || 0) + (uses7 || 0) * 5;
+ if (score <= 0) return null;
+ return {
+ id, kind, name, url,
+ icon: idOf(row.icon) || id,
+ score: Math.round(score),
+ opens_24h: opens24, opens_7d: opens7, opens_prev_7d: opensPrev7,
+ growth_7d_pct: growth, uses_24h: uses24, uses_7d: uses7,
+ };
+}
+
+function reasonOf(item, topToday) {
+ if (item.opens_24h != null && item.opens_24h > 0 && item.opens_24h === topToday) return 'Most used today';
+ if (item.growth_7d_pct != null && item.growth_7d_pct >= 10 && (item.opens_7d || 0) > 0) return 'Rising this week';
+ return 'Popular this week';
+}
+
+function createPopularity({ store = null, now = () => Date.now(), ttlMs = TTL_MS, minPeople = MIN_PEOPLE, statsOf = null, coRowsOf = null, log = console } = {}) {
+ const db = store && store.db ? store.db : null;
+ const clock = typeof now === 'function' ? now : () => Date.now();
+ const stats = statsOf || ((days) => reportStats(store, days, { now: clock }));
+ const coRows = coRowsOf || (async () => {
+ if (!db) return null;
+ return db.many(`SELECT snapshot FROM report_me_stats
+ WHERE COALESCE(published->>'sites', 'false') = 'true' AND snapshot IS NOT NULL
+ ORDER BY updated_at DESC LIMIT 5000`);
+ });
+ const cache = new Map();
+ const inflight = new Map();
+
+ function warn(what, err) {
+ try { (log.warn || log.error || console.warn).call(log, `[OpenVibe.Report] ${what} failed: ${String((err && err.message) || err).replace(/\s+/g, ' ').slice(0, 200)}`); } catch { /* logging never fails an answer */ }
+ }
+
+ /** Five-minute memo: a promise is shared while it runs; a failure is not cached. */
+ function memo(key, fn) {
+ const hit = cache.get(key);
+ if (hit && clock() - hit.at < ttlMs) return Promise.resolve(hit.value);
+ if (inflight.has(key)) return inflight.get(key);
+ const p = Promise.resolve().then(fn).then((value) => {
+ cache.set(key, { at: clock(), value });
+ return value;
+ }).finally(() => inflight.delete(key));
+ inflight.set(key, p);
+ return p;
+ }
+
+ async function buildPopular() {
+ if (!db && !statsOf) return null;
+ let body;
+ try { body = await stats(7); } catch (err) { warn('popular stats', err); return null; }
+ if (!body || typeof body !== 'object') return null;
+ const candidates = [];
+ for (const site of Array.isArray(body.sites) ? body.sites : []) {
+ if (!site || site.id === 'network') continue; // the account desk is not a destination
+ const item = destination('site', site);
+ if (item) candidates.push(item);
+ }
+ for (const tool of Array.isArray(body.tools) ? body.tools : []) {
+ const item = destination('tool', tool);
+ if (item) candidates.push(item);
+ }
+ candidates.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
+ const top = candidates.slice(0, 50);
+ const topToday = top.reduce((best, item) => (item.opens_24h != null && item.opens_24h > best ? item.opens_24h : best), 0);
+ const generatedAt = body.generated_at || new Date(clock()).toISOString();
+ return {
+ source: 'report',
+ generated_at: generatedAt,
+ window: {
+ days: 7,
+ today: new Date(clock()).toISOString().slice(0, 10),
+ opens_24h: 'page views counted since 00:00 UTC today',
+ uses_24h: 'distinct signed-in people in the last 24 hours',
+ opens_7d: 'page views over the last seven days',
+ },
+ items: top.map((item) => ({ ...item, reason: reasonOf(item, topToday) })),
+ };
+ }
+
+ async function popular({ limit = 5 } = {}) {
+ const full = await memo('popular', buildPopular);
+ if (!full) return null;
+ return { ...full, items: full.items.slice(0, clampLimit(limit)) };
+ }
+
+ async function buildCoUsage() {
+ if (!db && !coRowsOf) return null;
+ const rows = await coRows();
+ if (rows == null) return null;
+ const counts = new Map();
+ let sample = 0;
+ for (const row of rows) {
+ let snapshot = row && row.snapshot;
+ if (typeof snapshot === 'string') { try { snapshot = JSON.parse(snapshot); } catch { snapshot = null; } }
+ const month = snapshot && snapshot.sites && Array.isArray(snapshot.sites.month) ? snapshot.sites.month : [];
+ const services = new Set();
+ for (const entry of month) {
+ const service = String((entry && entry.service) || '').trim().toLowerCase();
+ if (/^[a-z0-9-]{1,32}$/.test(service) && service !== 'network') services.add(service);
+ }
+ if (services.size < 2) continue;
+ sample += 1;
+ const list = [...services].sort();
+ for (let i = 0; i < list.length; i++) {
+ for (let j = i + 1; j < list.length; j++) {
+ const key = `${list[i]}|${list[j]}`;
+ counts.set(key, (counts.get(key) || 0) + 1);
+ }
+ }
+ }
+ const pairs = [...counts.entries()]
+ .map(([key, people]) => { const [a, b] = key.split('|'); return { a, b, people }; })
+ .filter((p) => p.people >= minPeople)
+ .sort((a, b) => b.people - a.people || a.a.localeCompare(b.a) || a.b.localeCompare(b.b))
+ .slice(0, MAX_PAIRS);
+ return {
+ source: 'report',
+ generated_at: new Date(clock()).toISOString(),
+ sample_people: sample,
+ min_people: minPeople,
+ pairs,
+ };
+ }
+
+ function coUsage() { return memo('co-usage', buildCoUsage); }
+
+ return { popular, coUsage, ttlMs, minPeople, _clear() { cache.clear(); } };
+}
+
+module.exports = { createPopularity, TTL_MS, MIN_PEOPLE, destination, reasonOf };
@@ -48,6 +48,14 @@ const { boot, check, done, defaults } = require('./helpers/boot');
@@ -48,6 +48,14 @@ const { boot, check, done, defaults } = require('./helpers/boot');
assert.strictEqual(t.fetchImpl.calls.length, before, 'a bad parameter reached the source');
assert.strictEqual(t.fetchImpl.calls.length, before, 'a bad parameter reached the source');
});
});
+ await check('the popularity routes answer problem+json when Report has no numbers', async () => {
+ for (const path of ['/api/v1/popular', '/api/v1/popular/co-usage']) {
+ const r = await t.get(path);
+ assert.strictEqual(r.status, 404, path);
+ assert.match(r.headers.get('content-type') || '', /^application\/problem\+json/);
+ }
+ });
+
await check('the API is read-only', async () => {
await check('the API is read-only', async () => {
const r = await t.get('/api/v1/stats', { method: 'POST' });
const r = await t.get('/api/v1/stats', { method: 'POST' });
assert.strictEqual(r.status, 405);
assert.strictEqual(r.status, 405);
new file mode 100644
new file mode 100644
@@ -0,0 +1,117 @@
@@ -0,0 +1,117 @@
+'use strict';
+
+/**
+ * The front door's popularity read and "Try next"'s co-usage (plan L43): Report's own tables answer the ranked
+ * destinations, a tool is a destination beside a site, the co-usage answer only reads people who published their
+ * site list, and both are held five minutes. A pair below the small-group floor is never returned.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { createPopularity, TTL_MS, MIN_PEOPLE } = require('../server/popularity');
+
+const DAY = 86_400_000;
+const NOW = Date.parse('2026-11-09T12:00:00Z');
+const day = (back) => new Date(NOW - back * DAY).toISOString().slice(0, 10);
+const series = (values) => values.map((n, i) => ({ day: day(values.length - 1 - i), n }));
+
+function statsFixture() {
+ return {
+ generated_at: new Date(NOW).toISOString(),
+ window_days: 7,
+ sites: [
+ { id: 'live', name: 'OpenVibe.Live', short: 'Live', host: 'openvibe.live', url: 'https://openvibe.live/', icon: 'live', pageviews: 700, pageviews_prev: 500, change_pct: 40, opens_24h: 100, opens_7d: 700, opens_prev_7d: 500, uses_24h: 12, uses_7d: 40, daily: series([100, 100, 100, 100, 100, 100, 100]) },
+ { id: 'tools', name: 'OpenVibe.Tools', short: 'Tools', host: 'openvibe.tools', url: 'https://openvibe.tools/', icon: 'tools', pageviews: 500, pageviews_prev: 400, change_pct: 25, opens_24h: 80, opens_7d: 500, opens_prev_7d: 400, uses_24h: 8, uses_7d: 20, daily: series([70, 70, 70, 70, 70, 75, 75]) },
+ { id: 'chat', name: 'OpenVibe.Chat', short: 'Chat', host: 'openvibe.chat', url: 'https://openvibe.chat/', icon: 'chat', pageviews: 100, pageviews_prev: 110, change_pct: -9, opens_24h: 10, opens_7d: 100, opens_prev_7d: 110, daily: series([15, 15, 15, 15, 15, 15, 10]) },
+ { id: 'network', name: 'OpenVibe.Network', short: 'Network', host: 'openvibe.network', url: 'https://openvibe.network/', icon: 'network', pageviews: 9999, change_pct: 999 },
+ ],
+ tools: [
+ { id: 'yt', name: 'YouTube downloader', short: 'yt', host: 'yt.openvibe.tools', url: 'https://yt.openvibe.tools/', icon: 'yt', opens_24h: 200, opens_7d: 900, opens_prev_7d: 300, uses_24h: 4, uses_7d: 11, daily: series([100, 100, 100, 100, 100, 200, 200]) },
+ { id: 'dns', name: 'DNS lookup', short: 'dns', host: 'dns.openvibe.tools', url: 'https://dns.openvibe.tools/', icon: 'dns', opens_24h: 20, opens_7d: 150, opens_prev_7d: 100, daily: series([20, 20, 20, 20, 25, 25, 20]) },
+ ],
+ };
+}
+
+(async () => {
+ await check('the ranked list is sites and tools from Report\'s numbers, with a reason and the icon id', async () => {
+ const t = await boot({ db: true, now: () => NOW });
+ try {
+ const ingest = await t.ctx.analytics.ingestStats(statsFixture(), { now: () => NOW });
+ assert.ok(ingest.ok, 'the stats fixture is ingested');
+ const stats = await t.ctx.analytics.reportStats(7);
+ assert.deepStrictEqual(stats.tools.map((tool) => tool.id), ['yt', 'dns'], 'tools are in the stats shape, ranked');
+ assert.strictEqual(stats.tools[0].opens_7d, 900);
+ assert.strictEqual(stats.sites.find((s) => s.id === 'live').uses_7d, 40, 'the people count is kept');
+ const body = await t.ctx.popularity.popular({ limit: 5 });
+ assert.strictEqual(body.source, 'report');
+ assert.ok(body.items.length >= 4, 'four destinations with numbers');
+ assert.strictEqual(body.items[0].id, 'yt', 'the most opened destination leads');
+ assert.strictEqual(body.items[0].kind, 'tool', 'a tool is a destination');
+ assert.strictEqual(body.items[0].reason, 'Most used today');
+ assert.ok(body.items.every((i) => i.url.startsWith('https://') && i.icon && i.reason && i.name), 'every item has a URL, icon id, reason and name');
+ assert.ok(!body.items.some((i) => i.id === 'network'), 'the account desk is not a destination');
+ const live = body.items.find((i) => i.id === 'live');
+ assert.strictEqual(live.kind, 'site');
+ assert.strictEqual(live.opens_7d, 700);
+ assert.strictEqual(live.opens_24h, 100);
+ assert.strictEqual(live.uses_7d, 40);
+ assert.strictEqual(live.reason, 'Rising this week');
+
+ const api = await t.get('/api/v1/popular?limit=2');
+ assert.strictEqual(api.status, 200);
+ assert.strictEqual(api.headers.get('cache-control'), 'public, max-age=300', 'held five minutes');
+ assert.strictEqual(api.headers.get('access-control-allow-origin'), '*');
+ const json = api.json();
+ assert.strictEqual(json.items.length, 2);
+ assert.deepStrictEqual(json.items.map((i) => i.id), ['yt', 'live']);
+ assert.ok(json.window && /00:00 UTC/.test(json.window.opens_24h));
+ } finally { await t.close(); }
+ });
+
+ await check('the co-usage list reads only published site lists, aggregates pairs, and hides a small group', async () => {
+ const t = await boot({ db: true, now: () => NOW });
+ try {
+ const db = t.ctx.store.db;
+ const insert = db.prepare(`INSERT INTO report_me_stats (subject, username, display_name, published, snapshot, snapshot_at, updated_at)
+ VALUES (?, ?, ?, ?::jsonb, ?::jsonb, now(), now())`);
+ const row = (id, month, published = true) => insert.run(`usr_${id}`, `person${id}`, `Person ${id}`,
+ JSON.stringify({ sites: published }), JSON.stringify({ sites: { month: month.map((service) => ({ service, visits: 2 })) } }));
+ await row('a', ['live', 'tools', 'chat']);
+ await row('b', ['live', 'tools']);
+ await row('c', ['live', 'tools', 'games']);
+ await row('d', ['live', 'tools', 'chat']);
+ await row('e', ['live', 'tools']);
+ await row('f', ['live', 'tools'], false);
+
+ const body = await t.ctx.popularity.coUsage();
+ assert.strictEqual(body.source, 'report');
+ assert.strictEqual(body.sample_people, 5, 'the unpublished row is not read');
+ assert.strictEqual(body.min_people, MIN_PEOPLE);
+ assert.deepStrictEqual(body.pairs, [{ a: 'live', b: 'tools', people: 5 }], 'only a pair with enough people is returned');
+ assert.ok(!JSON.stringify(body).includes('person'), 'no username or display name leaves the request');
+ const r = await t.get('/api/v1/popular/co-usage');
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=300');
+ assert.deepStrictEqual(r.json().pairs, body.pairs);
+ } finally { await t.close(); }
+ });
+
+ await check('both reads are memoized for five minutes', async () => {
+ let at = NOW;
+ let calls = 0;
+ const pop = createPopularity({ now: () => at, statsOf: async () => { calls++; return statsFixture(); } });
+ await pop.popular({ limit: 5 });
+ await pop.popular({ limit: 5 });
+ assert.strictEqual(calls, 1, 'the second read inside five minutes is the memo');
+ at += TTL_MS;
+ await pop.popular({ limit: 5 });
+ assert.strictEqual(calls, 2, 'after five minutes it is read again');
+ });
+
+ await check('a stats answer with no destinations is an empty list, never a made-up one', async () => {
+ const pop = createPopularity({ now: () => NOW, statsOf: async () => ({ generated_at: new Date(NOW).toISOString(), window_days: 7, sites: [], tools: [] }) });
+ const body = await pop.popular();
+ assert.deepStrictEqual(body.items, []);
+ });
+
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });