fenjaops: surface engagement events on admin page

Adds GET /api/fenjaops/events and three new panels: per-type totals
+ device breakdown, per-user summary (logins / timeline views / last
seen), and a raw event log capped at the newest 500 rows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arlind Ukshini 2026-04-27 11:08:33 +02:00
parent 10c614faae
commit 9f742928d5
3 changed files with 138 additions and 2 deletions

View file

@ -146,15 +146,100 @@ function renderClicks(rows) {
`).join(''); `).join('');
} }
// ─── Engagement events ───────────────────────────────────────
// Renders the four event views populated from /api/fenjaops/events:
// per-type totals, device breakdown, per-user summary, and the raw
// event log. Empty payloads are handled the same way as the joins
// tables — hide the table and show the .empty paragraph.
function parseMeta(s) {
if (!s) return null;
try { return JSON.parse(s); } catch { return null; }
}
function metaCompact(m) {
if (!m || typeof m !== 'object') return '';
return Object.entries(m).map(([k, v]) => `${k}=${v}`).join(' ');
}
function deviceLabel(r) {
return [r.device_type, r.os, r.browser].filter(Boolean).join('/') || '?';
}
function renderEventTotals(byType) {
// byType is an array of {event_type, total, unique_users} rows.
// Pivot onto the four stat tiles. Missing types render as 0 rather
// than '' once the fetch has completed.
const find = (t) => byType.find((r) => r.event_type === t) || { total: 0, unique_users: 0 };
const login = find('login');
const tlv = find('timeline_view');
document.getElementById('stat-event-logins').textContent = login.total;
document.getElementById('stat-event-tlv').textContent = tlv.total;
document.getElementById('stat-event-login-users').textContent = login.unique_users;
document.getElementById('stat-event-tlv-users').textContent = tlv.unique_users;
}
function renderEventDevices(rows) {
const wrap = document.getElementById('event-devices');
if (!rows || !rows.length) { wrap.innerHTML = ''; return; }
wrap.innerHTML = rows.map((r) => `
<div class="stat">
<span class="stat-k">${escapeHtml(r.device_type)}</span>
<span class="stat-v">${r.n}</span>
</div>
`).join('');
}
function renderEventSummary(rows) {
const tbody = document.querySelector('#t-event-summary tbody');
const empty = document.getElementById('empty-event-summary');
if (!rows.length) {
tbody.innerHTML = '';
empty.hidden = false;
return;
}
empty.hidden = true;
tbody.innerHTML = rows.map((r) => `
<tr>
<td>${escapeHtml(r.email)}</td>
<td class="num">${r.logins}</td>
<td class="num">${r.timeline_views}</td>
<td class="when">${iso(r.last_seen)}</td>
</tr>
`).join('');
}
function renderEvents(rows) {
const tbody = document.querySelector('#t-events tbody');
const empty = document.getElementById('empty-events');
if (!rows.length) {
tbody.innerHTML = '';
empty.hidden = false;
return;
}
empty.hidden = true;
tbody.innerHTML = rows.map((r) => `
<tr>
<td class="when">${iso(r.occurred_at)}</td>
<td>${escapeHtml(r.event_type)}</td>
<td>${escapeHtml(r.email)}</td>
<td>${escapeHtml(deviceLabel(r))}</td>
<td class="mono">${escapeHtml(shortSession(r.session_id))}</td>
<td>${escapeHtml(metaCompact(parseMeta(r.meta)))}</td>
</tr>
`).join('');
}
async function load() { async function load() {
try { try {
const [invitesRes, joinsRes, meRes] = await Promise.all([ const [invitesRes, joinsRes, eventsRes, meRes] = await Promise.all([
fetch('/api/fenjaops/invites', { credentials: 'same-origin' }), fetch('/api/fenjaops/invites', { credentials: 'same-origin' }),
fetch('/api/fenjaops/joins', { credentials: 'same-origin' }), fetch('/api/fenjaops/joins', { credentials: 'same-origin' }),
fetch('/api/fenjaops/events', { credentials: 'same-origin' }),
fetch('/auth/me', { credentials: 'same-origin' }), fetch('/auth/me', { credentials: 'same-origin' }),
]); ]);
if (!invitesRes.ok || !joinsRes.ok) { if (!invitesRes.ok || !joinsRes.ok || !eventsRes.ok) {
// Session expired or admin flag revoked while the page was open — // Session expired or admin flag revoked while the page was open —
// bounce to the front page rather than leaving stale tables. // bounce to the front page rather than leaving stale tables.
window.location.href = '/'; window.location.href = '/';
@ -172,6 +257,7 @@ async function load() {
const invites = await invitesRes.json(); const invites = await invitesRes.json();
const joins = await joinsRes.json(); const joins = await joinsRes.json();
const events = await eventsRes.json();
document.getElementById('stat-clicks').textContent = joins.total_clicks; document.getElementById('stat-clicks').textContent = joins.total_clicks;
document.getElementById('stat-unique').textContent = joins.unique_users; document.getElementById('stat-unique').textContent = joins.unique_users;
@ -181,6 +267,10 @@ async function load() {
renderInvites(invites); renderInvites(invites);
renderSummary(joins.summary); renderSummary(joins.summary);
renderClicks(joins.clicks); renderClicks(joins.clicks);
renderEventTotals(events.by_type);
renderEventDevices(events.device_breakdown);
renderEventSummary(events.summary);
renderEvents(events.events);
} catch (err) { } catch (err) {
// Network failure — show a soft error rather than a blank page. // Network failure — show a soft error rather than a blank page.
const stats = document.getElementById('stats'); const stats = document.getElementById('stats');

View file

@ -49,6 +49,39 @@
<p class="empty" id="empty-summary" hidden>No join clicks yet.</p> <p class="empty" id="empty-summary" hidden>No join clicks yet.</p>
</section> </section>
<section class="panel">
<h2>Engagement events <span class="dim">— totals</span></h2>
<div class="stats" id="event-totals">
<div class="stat"><span class="stat-k">Logins</span><span class="stat-v" id="stat-event-logins"></span></div>
<div class="stat"><span class="stat-k">Timeline views</span><span class="stat-v" id="stat-event-tlv"></span></div>
<div class="stat"><span class="stat-k">Login users</span><span class="stat-v" id="stat-event-login-users"></span></div>
<div class="stat"><span class="stat-k">Timeline users</span><span class="stat-v" id="stat-event-tlv-users"></span></div>
</div>
<div class="stats" id="event-devices" style="margin-top:.6rem"></div>
</section>
<section class="panel">
<h2>Per-user engagement</h2>
<table class="t" id="t-event-summary">
<thead><tr>
<th>Email</th><th class="num">Logins</th><th class="num">Timeline</th><th>Last seen</th>
</tr></thead>
<tbody></tbody>
</table>
<p class="empty" id="empty-event-summary" hidden>No events yet.</p>
</section>
<section class="panel">
<h2>Raw event log <span class="dim">(newest 500)</span></h2>
<table class="t" id="t-events">
<thead><tr>
<th>When</th><th>Type</th><th>Email</th><th>Device</th><th class="mono">Session</th><th>Meta</th>
</tr></thead>
<tbody></tbody>
</table>
<p class="empty" id="empty-events" hidden>No events logged yet.</p>
</section>
<section class="panel"> <section class="panel">
<h2>Invites</h2> <h2>Invites</h2>
<table class="t" id="t-invites"> <table class="t" id="t-invites">

View file

@ -170,6 +170,19 @@ app.get('/api/fenjaops/joins', requireAuth, requireAdmin, (req, res) => {
}); });
}); });
// Engagement events read-out — login + timeline_view rows with parsed
// device fields. Capped at the 500 most recent events so the JSON
// payload stays small as the table grows. CLI (`bin/events.js`) is
// the path for unbounded queries.
app.get('/api/fenjaops/events', requireAuth, requireAdmin, (req, res) => {
res.json({
events: q.listEvents.all(500),
summary: q.summariseEvents.all(),
by_type: q.countEventsByType.all(),
device_breakdown: q.deviceBreakdown.all(),
});
});
// ─── Root dispatch ─────────────────────────────────────────── // ─── Root dispatch ───────────────────────────────────────────
// GET / → always the entrance shell. If authed, entrance.js routes // GET / → always the entrance shell. If authed, entrance.js routes
// the user to the welcome step client-side. // the user to the welcome step client-side.