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:
parent
10c614faae
commit
9f742928d5
3 changed files with 138 additions and 2 deletions
|
|
@ -146,15 +146,100 @@ function renderClicks(rows) {
|
|||
`).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() {
|
||||
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/joins', { credentials: 'same-origin' }),
|
||||
fetch('/api/fenjaops/events', { 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 —
|
||||
// bounce to the front page rather than leaving stale tables.
|
||||
window.location.href = '/';
|
||||
|
|
@ -172,6 +257,7 @@ async function load() {
|
|||
|
||||
const invites = await invitesRes.json();
|
||||
const joins = await joinsRes.json();
|
||||
const events = await eventsRes.json();
|
||||
|
||||
document.getElementById('stat-clicks').textContent = joins.total_clicks;
|
||||
document.getElementById('stat-unique').textContent = joins.unique_users;
|
||||
|
|
@ -181,6 +267,10 @@ async function load() {
|
|||
renderInvites(invites);
|
||||
renderSummary(joins.summary);
|
||||
renderClicks(joins.clicks);
|
||||
renderEventTotals(events.by_type);
|
||||
renderEventDevices(events.device_breakdown);
|
||||
renderEventSummary(events.summary);
|
||||
renderEvents(events.events);
|
||||
} catch (err) {
|
||||
// Network failure — show a soft error rather than a blank page.
|
||||
const stats = document.getElementById('stats');
|
||||
|
|
|
|||
|
|
@ -49,6 +49,39 @@
|
|||
<p class="empty" id="empty-summary" hidden>No join clicks yet.</p>
|
||||
</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">
|
||||
<h2>Invites</h2>
|
||||
<table class="t" id="t-invites">
|
||||
|
|
|
|||
13
server.js
13
server.js
|
|
@ -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 ───────────────────────────────────────────
|
||||
// GET / → always the entrance shell. If authed, entrance.js routes
|
||||
// the user to the welcome step client-side.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue