openvibers/openvibe.report Commits

Report: rank the front door's popular destinations (L43) (#160)

OP OpenVibers committed 4 days ago · 8123924fcc · 8 files, +404 −5

Adds /api/v1/popular and /api/v1/popular/co-usage from Report's own tables: sites and tools with opens today and over seven days, growth, and the distinct signed-in people Network counts. Co-usage reads only people who published their site list and omits pairs below three people. Ingest and reportStats learn tool rows and rolling windows; both answers are held five minutes.

Changes

ddiff --git a/README.md b/README.md
iindex f446da6..aa06328 100644
---- a/README.md
++++ b/README.md
@@@ -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 omitted
GET /api/v1/rundown
GET /api/v1/rundown/topics
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
(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.
++`/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
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.
ddiff --git a/server/analytics/ingest.js b/server/analytics/ingest.js
iindex ed6525e..2ea6069 100644
---- a/server/analytics/ingest.js
++++ b/server/analytics/ingest.js
@@@ -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;
}
ddiff --git a/server/analytics/query.js b/server/analytics/query.js
iindex 29760a5..ffaf4db 100644
---- a/server/analytics/query.js
++++ b/server/analytics/query.js
@@@ -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 } : {}),
};
}
ddiff --git a/server/app.js b/server/app.js
iindex 8710ef0..47c7fe9 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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 ─────────────────────
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 2398405..a6a415d 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -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, analytics
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.
r.get('/design-health', (req, res) => res.json(designHealth.all()));
ddiff --git a/server/popularity.js b/server/popularity.js
nnew file mode 100644
iindex 0000000..c213fd6
---- /dev/null
++++ b/server/popularity.js
@@@ -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 };
ddiff --git a/test/api.test.js b/test/api.test.js
iindex 99e83b8..e22a322 100644
---- a/test/api.test.js
++++ b/test/api.test.js
@@@ -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);
ddiff --git a/test/popularity.test.js b/test/popularity.test.js
nnew file mode 100644
iindex 0000000..dbaf145
---- /dev/null
++++ b/test/popularity.test.js
@@@ -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); });