Admin Traffic tab: referring domains, landing pages, angles, by day

traffic.js logs public page views by referring domain (30 s buffered; page_hits
table or traffic.json), coach exposes all join-page views, and
/api/admin/traffic merges page views, join-page views, signups, registrations
and $20+ buyers by first-touch source for 7/30/90/365-day ranges.
Also: line-tree chips no longer truncate own-position labels.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-12 07:51:01 -05:00
parent abc76c47c8
commit ef088601c3
7 changed files with 131 additions and 4 deletions
+8 -2
View File
@@ -104,7 +104,8 @@ const J = {
async addView(token, angle, ts, ref) { this.db.views.push({ token, angle, ts, ref: ref || '' }); if (this.db.views.length > 50000) this.db.views = this.db.views.slice(-40000); this.save(); }, async addView(token, angle, ts, ref) { this.db.views.push({ token, angle, ts, ref: ref || '' }); if (this.db.views.length > 50000) this.db.views = this.db.views.slice(-40000); this.save(); },
async addClick(campaignId, src, ts) { this.db.clicks = this.db.clicks || []; this.db.clicks.push({ campaignId, src, ts }); if (this.db.clicks.length > 50000) this.db.clicks = this.db.clicks.slice(-40000); this.save(); }, async addClick(campaignId, src, ts) { this.db.clicks = this.db.clicks || []; this.db.clicks.push({ campaignId, src, ts }); if (this.db.clicks.length > 50000) this.db.clicks = this.db.clicks.slice(-40000); this.save(); },
async clicksFor(ids) { return (this.db.clicks || []).filter(c => ids.includes(c.campaignId)); }, async clicksFor(ids) { return (this.db.clicks || []).filter(c => ids.includes(c.campaignId)); },
async views(tokens, since) { return this.db.views.filter(v => tokens.includes(v.token) && v.ts >= since); } async views(tokens, since) { return this.db.views.filter(v => tokens.includes(v.token) && v.ts >= since); },
async viewsSince(since) { return this.db.views.filter(v => v.ts >= since); }
}; };
const D = { const D = {
async lastNudge(email) { const r = await db.q('SELECT rung, ts FROM nudges WHERE email=?', [email]); return r[0] ? { rung: r[0].rung, ts: Number(r[0].ts) } : null; }, async lastNudge(email) { const r = await db.q('SELECT rung, ts FROM nudges WHERE email=?', [email]); return r[0] ? { rung: r[0].rung, ts: Number(r[0].ts) } : null; },
@@ -131,6 +132,10 @@ const D = {
if (!tokens.length) return []; if (!tokens.length) return [];
const rows = await db.q('SELECT token, angle, ts, ref FROM join_views WHERE ts>=? AND token IN (' + tokens.map(() => '?').join(',') + ')', [since, ...tokens]); const rows = await db.q('SELECT token, angle, ts, ref FROM join_views WHERE ts>=? AND token IN (' + tokens.map(() => '?').join(',') + ')', [since, ...tokens]);
return rows.map(r => ({ token: r.token, angle: r.angle, ts: Number(r.ts), ref: r.ref || '' })); return rows.map(r => ({ token: r.token, angle: r.angle, ts: Number(r.ts), ref: r.ref || '' }));
},
async viewsSince(since) {
const rows = await db.q('SELECT token, angle, ts, ref FROM join_views WHERE ts>=?', [since]);
return rows.map(r => ({ token: r.token, angle: r.angle, ts: Number(r.ts), ref: r.ref || '' }));
} }
}; };
const impl = () => db.enabled() ? D : J; const impl = () => db.enabled() ? D : J;
@@ -249,4 +254,5 @@ function init(opts) {
DATA_DIR = opts.dataDir; chain = opts.chain; accounts = opts.accounts; mailer = opts.mailer; DATA_DIR = opts.dataDir; chain = opts.chain; accounts = opts.accounts; mailer = opts.mailer;
J.load(); J.load();
} }
module.exports = { init, RUNGS, rungFor, describe, coachView, prospects, saveProspect, removeProspect, STATUSES, recordView, linkStats, nudgeTick, recordClick, clickSources, refHost }; async function viewsSince(since) { return impl().viewsSince(since); }
module.exports = { init, RUNGS, rungFor, describe, coachView, prospects, saveProspect, removeProspect, STATUSES, recordView, linkStats, nudgeTick, recordClick, clickSources, refHost, viewsSince };
+4
View File
@@ -144,6 +144,10 @@ async function bootstrap() {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
await q(`CREATE TABLE IF NOT EXISTS nudges (email VARCHAR(190) PRIMARY KEY, rung INT NOT NULL, ts BIGINT NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); await q(`CREATE TABLE IF NOT EXISTS nudges (email VARCHAR(190) PRIMARY KEY, rung INT NOT NULL, ts BIGINT NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
await q(`CREATE TABLE IF NOT EXISTS digests (email VARCHAR(190) PRIMARY KEY, ts BIGINT NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); await q(`CREATE TABLE IF NOT EXISTS digests (email VARCHAR(190) PRIMARY KEY, ts BIGINT NOT NULL) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
await q(`CREATE TABLE IF NOT EXISTS page_hits (
day CHAR(10) NOT NULL, host VARCHAR(80) NOT NULL, path VARCHAR(40) NOT NULL, n INT NOT NULL DEFAULT 0,
PRIMARY KEY (day, host, path)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); // admin Traffic tab: public page views by referring domain
await q(`CREATE TABLE IF NOT EXISTS adoptions ( await q(`CREATE TABLE IF NOT EXISTS adoptions (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
adoptee VARCHAR(190) NOT NULL, adopter VARCHAR(190) NOT NULL, adoptee VARCHAR(190) NOT NULL, adopter VARCHAR(190) NOT NULL,
+14
View File
@@ -90,6 +90,7 @@
<button data-pane="campaigns" type="button"><svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h10"/></svg>All campaigns</button> <button data-pane="campaigns" type="button"><svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h10"/></svg>All campaigns</button>
<button data-pane="members" type="button"><svg viewBox="0 0 24 24"><circle cx="12" cy="7" r="3.2"/><circle cx="5" cy="17" r="2.6"/><circle cx="19" cy="17" r="2.6"/><path d="M12 10v3M12 13l-5 2M12 13l5 2"/></svg>Members</button> <button data-pane="members" type="button"><svg viewBox="0 0 24 24"><circle cx="12" cy="7" r="3.2"/><circle cx="5" cy="17" r="2.6"/><circle cx="19" cy="17" r="2.6"/><path d="M12 10v3M12 13l-5 2M12 13l5 2"/></svg>Members</button>
<button data-pane="reports" type="button"><svg viewBox="0 0 24 24"><path d="M12 3l9 16H3z"/><path d="M12 10v4M12 17v.5"/></svg>Reports<span class="pill" id="repBadge" hidden></span></button> <button data-pane="reports" type="button"><svg viewBox="0 0 24 24"><path d="M12 3l9 16H3z"/><path d="M12 10v4M12 17v.5"/></svg>Reports<span class="pill" id="repBadge" hidden></span></button>
<button data-pane="traffic" type="button"><svg viewBox="0 0 24 24"><path d="M3 17l6-6 4 4 8-8"/><path d="M14 7h7v7"/></svg>Traffic</button>
<button data-pane="pnl" type="button"><svg viewBox="0 0 24 24"><path d="M4 19V5M4 19h16"/><path d="M8 15l3-4 3 2 5-6"/></svg>P&amp;L</button> <button data-pane="pnl" type="button"><svg viewBox="0 0 24 24"><path d="M4 19V5M4 19h16"/><path d="M8 15l3-4 3 2 5-6"/></svg>P&amp;L</button>
<button data-pane="settings" type="button"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg>Settings</button> <button data-pane="settings" type="button"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg>Settings</button>
</nav> </nav>
@@ -251,6 +252,19 @@
</div> </div>
</div> </div>
<div class="pane" id="pane-traffic" hidden>
<div class="card">
<div class="card-head"><h3>Where visitors come from</h3><span class="sub" id="trfSub"></span></div>
<div class="chips" id="trfRange" style="margin:0 0 10px"><button type="button" class="chip-t" data-days="7">7 days</button><button type="button" class="chip-t on" data-days="30">30 days</button><button type="button" class="chip-t" data-days="90">90 days</button><button type="button" class="chip-t" data-days="365">Year</button></div>
<p class="muted small" style="margin:0 0 10px">Page views are public-page loads by referring domain (crawlers skipped; our own pages and no referrer count as direct). Join-page views are invite-link opens. Signups, registered and $20+ buyers are accounts whose first-touch source was that domain; legacy arrivals show as legacy:brand:domain.</p>
<div class="tablewrap"><table class="adm-table" id="trfSources"></table></div>
</div>
<div class="grid c2">
<div class="card"><div class="card-head"><h3>Landing pages</h3><span class="sub">page views by page</span></div><div class="tablewrap"><table class="adm-table" id="trfPaths"></table></div></div>
<div class="card"><div class="card-head"><h3>Angles</h3><span class="sub">join-page hook copy</span></div><div class="tablewrap"><table class="adm-table" id="trfAngles"></table></div></div>
</div>
<div class="card"><div class="card-head"><h3>By day</h3><span class="sub">page views, signups</span></div><div class="tablewrap"><table class="adm-table" id="trfDaily"></table></div></div>
</div>
<div class="pane" id="pane-pnl" hidden> <div class="pane" id="pane-pnl" hidden>
<div class="card"> <div class="card">
<div class="card-head"><h3>Profit and loss</h3><span class="sub">read from the chain index; periods are by block (about 43,200 Polygon blocks a day)</span></div> <div class="card-head"><h3>Profit and loss</h3><span class="sub">read from the chain index; periods are by block (about 43,200 Polygon blocks a day)</span></div>
+16 -1
View File
@@ -45,7 +45,7 @@
// ── panes ── // ── panes ──
const TITLES = { overview: 'Overview', house: 'House ads', campaigns: 'All campaigns', members: 'Members', reports: 'Reports', pnl: 'Profit and loss', settings: 'Settings' }; const TITLES = { overview: 'Overview', house: 'House ads', campaigns: 'All campaigns', members: 'Members', reports: 'Reports', pnl: 'Profit and loss', settings: 'Settings' };
const loaders = { overview: loadOverview, house: loadHouse, campaigns: loadCampaigns, members: loadMembers, reports: loadReports, pnl: loadPnl, settings: loadSettings }; const loaders = { overview: loadOverview, house: loadHouse, campaigns: loadCampaigns, members: loadMembers, reports: loadReports, traffic: loadTraffic, pnl: loadPnl, settings: loadSettings };
function setPane(name) { function setPane(name) {
if (!TITLES[name]) name = 'overview'; if (!TITLES[name]) name = 'overview';
document.querySelectorAll('.pane').forEach(p => { p.hidden = p.id !== 'pane-' + name; }); document.querySelectorAll('.pane').forEach(p => { p.hidden = p.id !== 'pane-' + name; });
@@ -310,6 +310,21 @@
let pnlDays = 30; let pnlDays = 30;
const pol = w => { try { return (Number(BigInt(w || '0') / 10n ** 14n) / 10000).toLocaleString(undefined, { maximumFractionDigits: 2 }); } catch (e) { return '0'; } }; const pol = w => { try { return (Number(BigInt(w || '0') / 10n ** 14n) / 10000).toLocaleString(undefined, { maximumFractionDigits: 2 }); } catch (e) { return '0'; } };
const usdOf = (w, px) => { try { return '$' + ((Number(BigInt(w || '0') / 10n ** 14n) / 10000) * px).toLocaleString(undefined, { maximumFractionDigits: 0 }); } catch (e) { return '$0'; } }; const usdOf = (w, px) => { try { return '$' + ((Number(BigInt(w || '0') / 10n ** 14n) / 10000) * px).toLocaleString(undefined, { maximumFractionDigits: 0 }); } catch (e) { return '$0'; } };
// ── traffic: referring domains / sources, landing pages, angles, by day ──
let trfDays = 30;
document.querySelectorAll('#trfRange [data-days]').forEach(b => b.addEventListener('click', () => { trfDays = Number(b.dataset.days); document.querySelectorAll('#trfRange [data-days]').forEach(x => x.classList.toggle('on', x === b)); loadTraffic().catch(e => IAP.status(e.message, 'bad')); }));
async function loadTraffic() {
const d = await (await fetch('/api/admin/traffic?days=' + trfDays)).json();
if (d.error) throw new Error(d.error);
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const n = v => Number(v || 0).toLocaleString();
$('trfSub').textContent = 'last ' + d.days + ' days · ' + n(d.totals.hits) + ' page views · ' + n(d.totals.joinViews) + ' join-page views · ' + n(d.totals.signups) + ' signups · ' + n(d.totals.buyers) + ' buyers';
$('trfSources').innerHTML = '<tr><th>Source</th><th>Page views</th><th>Join-page views</th><th>Signups</th><th>Registered</th><th>$20+ buyers</th></tr>'
+ (d.sources.length ? d.sources.map(s => '<tr><td>' + esc(s.source) + '</td><td>' + n(s.hits) + '</td><td>' + n(s.joinViews) + '</td><td>' + n(s.signups) + '</td><td>' + n(s.registered) + '</td><td>' + n(s.buyers) + '</td></tr>').join('') : '<tr><td colspan="6" class="muted">Nothing recorded in this range yet.</td></tr>');
$('trfPaths').innerHTML = '<tr><th>Page</th><th>Views</th></tr>' + (d.paths.length ? d.paths.map(p => '<tr><td>' + esc(p.path) + '</td><td>' + n(p.hits) + '</td></tr>').join('') : '<tr><td colspan="2" class="muted">No page views yet.</td></tr>');
$('trfAngles').innerHTML = '<tr><th>Angle</th><th>Join-page views</th><th>Signups</th></tr>' + (d.angles.length ? d.angles.map(a => '<tr><td>' + esc(a.angle) + '</td><td>' + n(a.views) + '</td><td>' + n(a.signups) + '</td></tr>').join('') : '<tr><td colspan="3" class="muted">No angle data yet.</td></tr>');
$('trfDaily').innerHTML = '<tr><th>Day</th><th>Page views</th><th>Signups</th></tr>' + (d.daily.length ? d.daily.slice().reverse().map(x => '<tr><td>' + esc(x.day) + '</td><td>' + n(x.hits) + '</td><td>' + n(x.signups) + '</td></tr>').join('') : '<tr><td colspan="3" class="muted">Nothing yet.</td></tr>');
}
async function loadPnl() { async function loadPnl() {
const r = await api('/api/admin/pnl?days=' + pnlDays); const r = await api('/api/admin/pnl?days=' + pnlDays);
const px = r.polUsd || 0; const px = r.polUsd || 0;
+1 -1
View File
@@ -332,7 +332,7 @@
const levels = r.levels || []; const levels = r.levels || [];
const total = levels.reduce((n, L) => n + L.members.length, 0); const total = levels.reduce((n, L) => n + L.members.length, 0);
if ($('treeSub')) $('treeSub').textContent = total ? total + ' across 3 levels' : 'share your link to grow'; if ($('treeSub')) $('treeSub').textContent = total ? total + ' across 3 levels' : 'share your link to grow';
const chip = (m, q) => '<span class="lt-node' + (q ? ' qualified' : (m.email ? '' : ' deep')) + '" title="' + esc(m.name || 'member') + (q ? ' · qualified buyer' : '') + '">' + esc(String(m.name || 'M').replace(/^@/, '').slice(0, 12)) + '</span>'; const chip = (m, q) => '<span class="lt-node' + (q ? ' qualified' : (m.email ? '' : ' deep')) + '" title="' + esc(m.name || 'member') + (q ? ' · qualified buyer' : '') + '">' + esc(String(m.name || 'M').replace(/^@/, '').slice(0, m.own ? 20 : 14)) + '</span>';
const rowFor = lvl => { const rowFor = lvl => {
const L = levels.find(x => x.level === lvl); const members = L ? L.members : []; const L = levels.find(x => x.level === lvl); const members = L ? L.members : [];
// gold = the contract counted this member as one of your qualifying buyers (not "the first N chips") // gold = the contract counted this member as one of your qualifying buyers (not "the first N chips")
+28
View File
@@ -26,6 +26,8 @@ const chatbot = require('./chatbot');
const coach = require('./coach'); // coaching view, nudges, digest, prospects, link stats const coach = require('./coach'); // coaching view, nudges, digest, prospects, link stats
const tank = require('./tank'); // holding tank: unsponsored free members, adoptions, pay-it-forward const tank = require('./tank'); // holding tank: unsponsored free members, adoptions, pay-it-forward
const legacy = require('./legacy'); // Faucet Wave / Tier One Ads bridge: welcome-back credits for listed emails const legacy = require('./legacy'); // Faucet Wave / Tier One Ads bridge: welcome-back credits for listed emails
const traffic = require('./traffic'); // public page views by referring domain (admin Traffic tab)
const TRAFFIC_PAGES = new Set(['/', '/ledger', '/contract', '/shorts', '/plays', '/wallets', '/launch']);
let tankWaitCache = null; // dashboard: who is waiting for a sponsor (refreshed every minute) let tankWaitCache = null; // dashboard: who is waiting for a sponsor (refreshed every minute)
const geo = require('./geo'); // viewer country -> tier (DB-IP lite), for campaign targeting const geo = require('./geo'); // viewer country -> tier (DB-IP lite), for campaign targeting
const burner = require('./burner'); // automatic on-chain credit burns (inert without ENGINE_KEY) const burner = require('./burner'); // automatic on-chain credit burns (inert without ENGINE_KEY)
@@ -319,6 +321,7 @@ async function boot() {
coach.init({ dataDir: DATA_DIR, chain, accounts, mailer }); coach.init({ dataDir: DATA_DIR, chain, accounts, mailer });
tank.init({ dataDir: DATA_DIR, chain, accounts, messages, mailer, site: 'https://instantadpay.com' }); tank.init({ dataDir: DATA_DIR, chain, accounts, messages, mailer, site: 'https://instantadpay.com' });
legacy.init({ dataDir: DATA_DIR }); legacy.init({ dataDir: DATA_DIR });
traffic.init({ dataDir: DATA_DIR });
setInterval(() => tankNotifyTick().catch(e => console.error('tank notify', e.message)), 15 * 60 * 1000); // new tank arrivals -> Telegram setInterval(() => tankNotifyTick().catch(e => console.error('tank notify', e.message)), 15 * 60 * 1000); // new tank arrivals -> Telegram
geo.init({ dataDir: DATA_DIR }).catch(e => console.error('geo init', e.message)); geo.init({ dataDir: DATA_DIR }).catch(e => console.error('geo init', e.message));
setInterval(() => geo.refresh().catch(e => console.error('geo refresh', e.message)), 24 * 3600 * 1000); // monthly file, checked daily setInterval(() => geo.refresh().catch(e => console.error('geo refresh', e.message)), 24 * 3600 * 1000); // monthly file, checked daily
@@ -603,6 +606,8 @@ const server = http.createServer(async (req, res) => {
const u = new URL(req.url, 'http://x'); const u = new URL(req.url, 'http://x');
const p = u.pathname; const p = u.pathname;
// -- traffic log: public page views by referring domain (admin > Traffic)
if (req.method === 'GET' && (TRAFFIC_PAGES.has(p) || /^\/(join|from|wall)\/[^/]+$/.test(p))) traffic.hit(p, req.headers.referer, req.headers['user-agent']);
// -- join links: /join/<memberId or share code> — LAST-touch cookie (Marty, // -- join links: /join/<memberId or share code> — LAST-touch cookie (Marty,
// 2026-09-10): the link a visitor opened most recently is the sponsor shown // 2026-09-10): the link a visitor opened most recently is the sponsor shown
// and used, and it locks the moment the account is created (accounts.ensure // and used, and it locks the moment the account is created (accounts.ensure
@@ -1093,6 +1098,29 @@ const server = http.createServer(async (req, res) => {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
return json(res, 200, await tank.adminView()); return json(res, 200, await tank.adminView());
} }
// -- admin Traffic tab: page views + join-page views + signups + $20 buyers, by referring
// domain / first-touch source, by landing page, by angle and by day (Marty, 2026-09-12)
if (p === '/api/admin/traffic' && req.method === 'GET') {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
const days = Math.min(365, Math.max(1, Number(u.searchParams.get('days')) || 30));
const since = Date.now() - days * 86400000, sinceDay = new Date(since).toISOString().slice(0, 10);
const hosts = {}, paths = {}, daily = {}, angles = {};
const H = k => (hosts[k] = hosts[k] || { source: k, hits: 0, joinViews: 0, signups: 0, registered: 0, buyers: 0 });
const Dy = k => (daily[k] = daily[k] || { day: k, hits: 0, signups: 0 });
for (const r of await traffic.rows(sinceDay)) { H(r.host).hits += r.n; paths[r.path] = (paths[r.path] || 0) + r.n; Dy(r.day).hits += r.n; }
for (const v of await coach.viewsSince(since)) { H(v.ref || 'direct').joinViews += 1; const a = angles[v.angle || 'plain'] = angles[v.angle || 'plain'] || { angle: v.angle || 'plain', views: 0, signups: 0 }; a.views += 1; }
const buyerIds = new Set(); for (const ev of chain.recentEvents(1e9)) if (ev.type === 'BuyerCounted' && ev.newBuyerId) buyerIds.add(ev.newBuyerId);
for (const a of await accounts.listAll(5000)) {
if (!a.created || a.created < since) continue;
const h = H(a.joinedRef || 'direct'); h.signups += 1; if (a.memberId) h.registered += 1; if (a.memberId && buyerIds.has(a.memberId)) h.buyers += 1;
Dy(new Date(a.created).toISOString().slice(0, 10)).signups += 1;
const k = a.joinedVia || 'plain'; angles[k] = angles[k] || { angle: k, views: 0, signups: 0 }; angles[k].signups += 1;
}
const sources = Object.values(hosts).sort((x, y) => (y.hits + y.joinViews + y.signups * 10) - (x.hits + x.joinViews + x.signups * 10));
return json(res, 200, { days, sources, paths: Object.entries(paths).map(([path, hits]) => ({ path, hits })).sort((x, y) => y.hits - x.hits),
angles: Object.values(angles).sort((x, y) => y.views - x.views), daily: Object.values(daily).sort((x, y) => x.day < y.day ? -1 : 1),
totals: { hits: sources.reduce((n, s) => n + s.hits, 0), joinViews: sources.reduce((n, s) => n + s.joinViews, 0), signups: sources.reduce((n, s) => n + s.signups, 0), buyers: sources.reduce((n, s) => n + s.buyers, 0) } });
}
if (p === '/api/my/coach' && req.method === 'GET') { if (p === '/api/my/coach' && req.method === 'GET') {
const s = await auth.fromRequest(req); const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
+60
View File
@@ -0,0 +1,60 @@
// Page-hit log for the admin Traffic tab (Marty, 2026-09-12): which referring domains send
// visitors to the public pages, per day and per landing page. Counters are buffered in memory
// and flushed every 30 s: MySQL table page_hits (day, host, path, n) when the DB is on, else
// DATA_DIR/traffic.json. Obvious crawlers are skipped. Our own pages as referrer = 'direct'.
const fs = require('fs');
const path = require('path');
const db = require('./db');
let DATA_DIR = null, buf = {}, jsonStore = null, timer = null;
const BOT = /bot|crawl|spider|slurp|facebookexternalhit|preview|monitor|curl\/|wget|python-requests|headless/i;
function init(opts) {
DATA_DIR = opts.dataDir;
if (!timer) { timer = setInterval(() => flush().catch(() => {}), 30 * 1000); timer.unref(); }
}
function host(referer) {
try { const h = new URL(String(referer || '')).hostname.replace(/^www\./, '').toLowerCase(); return (!h || /instantadpay\.com$/.test(h)) ? 'direct' : h.slice(0, 80); } catch (e) { return 'direct'; }
}
function family(p) {
if (p === '/' || p === '') return 'home';
if (p.startsWith('/join/')) return 'join';
if (p.startsWith('/from/')) return p.slice(1, 40); // from/faucetwave, from/tieroneads
if (p.startsWith('/wall/')) return 'wall';
return p.replace(/^\//, '').slice(0, 40) || 'home'; // ledger, contract, shorts, plays ...
}
const day = ts => new Date(ts).toISOString().slice(0, 10);
function hit(p, referer, ua) {
if (BOT.test(String(ua || ''))) return;
const k = day(Date.now()) + '|' + host(referer) + '|' + family(p);
buf[k] = (buf[k] || 0) + 1;
}
function loadJson() {
if (jsonStore) return jsonStore;
try { jsonStore = JSON.parse(fs.readFileSync(path.join(DATA_DIR, 'traffic.json'), 'utf8')); } catch (e) { jsonStore = {}; }
return jsonStore;
}
async function flush() {
const pending = buf; buf = {};
const keys = Object.keys(pending); if (!keys.length) return;
if (db.enabled()) {
for (const k of keys) { const [d, h, f] = k.split('|'); await db.q('INSERT INTO page_hits (day,host,path,n) VALUES (?,?,?,?) ON DUPLICATE KEY UPDATE n=n+VALUES(n)', [d, h, f, pending[k]]); }
return;
}
const J = loadJson();
for (const k of keys) J[k] = (J[k] || 0) + pending[k];
// keep the JSON store bounded: drop days older than 400
const cutoff = day(Date.now() - 400 * 86400000);
for (const k of Object.keys(J)) if (k.slice(0, 10) < cutoff) delete J[k];
fs.writeFileSync(path.join(DATA_DIR, 'traffic.json'), JSON.stringify(J));
}
// rows since a day (inclusive): [{day, host, path, n}]
async function rows(sinceDay) {
await flush();
if (db.enabled()) {
const r = await db.q('SELECT day, host, path, n FROM page_hits WHERE day>=?', [sinceDay]);
return r.map(x => ({ day: x.day, host: x.host, path: x.path, n: Number(x.n) }));
}
const J = loadJson();
return Object.keys(J).filter(k => k.slice(0, 10) >= sinceDay).map(k => { const [d, h, p] = k.split('|'); return { day: d, host: h, path: p, n: J[k] }; });
}
module.exports = { init, hit, flush, rows, host, family };