diff --git a/admin/admin.js b/admin/admin.js
index dd78e3e..4547e34 100644
--- a/admin/admin.js
+++ b/admin/admin.js
@@ -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) => `
+
+ ${escapeHtml(r.device_type)}
+ ${r.n}
+
+ `).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) => `
+
+ | ${escapeHtml(r.email)} |
+ ${r.logins} |
+ ${r.timeline_views} |
+ ${iso(r.last_seen)} |
+
+ `).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) => `
+
+ | ${iso(r.occurred_at)} |
+ ${escapeHtml(r.event_type)} |
+ ${escapeHtml(r.email)} |
+ ${escapeHtml(deviceLabel(r))} |
+ ${escapeHtml(shortSession(r.session_id))} |
+ ${escapeHtml(metaCompact(parseMeta(r.meta)))} |
+
+ `).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');
diff --git a/admin/index.html b/admin/index.html
index 4a6a277..5d59b87 100644
--- a/admin/index.html
+++ b/admin/index.html
@@ -49,6 +49,39 @@
No join clicks yet.
+
+ Engagement events — totals
+
+
Logins–
+
Timeline views–
+
Login users–
+
Timeline users–
+
+
+
+
+
+ Per-user engagement
+
+
+ | Email | Logins | Timeline | Last seen |
+
+
+
+ No events yet.
+
+
+
+ Raw event log (newest 500)
+
+
+ | When | Type | Email | Device | Session | Meta |
+
+
+
+ No events logged yet.
+
+
Invites
diff --git a/server.js b/server.js
index 4bcc75c..c6ff8b4 100644
--- a/server.js
+++ b/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.