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 @@ +
+

Engagement events — totals

+
+
Logins
+
Timeline views
+
Login users
+
Timeline users
+
+
+
+ +
+

Per-user engagement

+ + + + + +
EmailLoginsTimelineLast seen
+ +
+ +
+

Raw event log (newest 500)

+ + + + + +
WhenTypeEmailDeviceSessionMeta
+ +
+

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.