customer-presentation/docs/superpowers/plans/2026-04-27-engagement-tracking.md
Arlind Ukshini 44f7a8c5d7 docs: implementation plan for engagement event tracking
9 tasks: schema → CLI → UA parser → wire UA into server → recorder
→ issueSession refactor → login event → timeline_view event → docs.
Each task is a self-contained commit with verification steps using
curl + the new events CLI (no test framework on this project).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 10:08:19 +02:00

928 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Engagement Tracking Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add server-side tracking of `login` and `timeline_view` events with device classification (mobile/tablet/desktop, OS, browser), readable via a new `bin/events.js` CLI.
**Architecture:** One unified `events` table in the existing SQLite DB (`data/fenja.sqlite`). A small UA parser in `src/ua.js` (also takes ownership of the existing `MOBILE_UA_RE` regex). A thin recorder in `src/events.js`. Wire-ups in `src/auth.js` (login) and `server.js` (timeline_view). One-line refactor to `src/sessions.js` so the new session ID flows out of `issueSession()` for the login event.
**Tech Stack:** Node 20+ ESM, Express, `better-sqlite3` (synchronous). No test framework (see "Verification" below). No new npm dependencies.
**Spec:** [`docs/superpowers/specs/2026-04-27-engagement-tracking-design.md`](../specs/2026-04-27-engagement-tracking-design.md)
## Verification approach
The project has no test suite, linter, or build step (see `CLAUDE.md`). Verification per task is one of:
- **Smoke**: `npm run dev` starts cleanly with no errors on stdout/stderr.
- **CLI inspection**: `node bin/events.js list` (after Task 2 lands) — the events CLI is itself a verification tool for later tasks.
- **Quick Node one-liner**: for pure functions like the UA parser.
- **Manual browser walk**: for end-to-end tasks (Tasks 78). Local dev runs at `http://127.0.0.1:3000`. Use an invite created via `node bin/invite.js add <email> <FirstName>`.
- **CHECKLIST.md walkthrough**: full manual matrix in Task 9.
Local dev requires `.env` with `PORT=3000` and `PUBLIC_ORIGIN=http://127.0.0.1:3000` (no `NODE_ENV=production` so cookies work over HTTP). See `.env.example`.
---
## File Structure
**New files:**
- `src/ua.js` — UA parser; owns `MOBILE_UA_RE` (~50 lines)
- `src/events.js``recordEvent()` recorder (~25 lines)
- `bin/events.js` — CLI for reading the event log (~110 lines, mirrors `bin/joins.js`)
**Modified files:**
- `src/db.js` — add `events` table, indexes, 7 prepared statements
- `src/sessions.js``issueSession()` returns the new session ID
- `src/auth.js` — capture session ID, record `login` event on success
- `server.js` — import `MOBILE_UA_RE` from `src/ua.js`; record `timeline_view` event in the `/timeline` handler
- `CLAUDE.md` — add `bin/events.js` to the commands block
- `OPERATIONS.md` — new section "Reading engagement events" mirroring "Reading Join-CTA clicks"
- `CHECKLIST.md` — new section "H3. After changes to engagement events"
---
## Task 1: Schema + prepared statements in `src/db.js`
**Files:**
- Modify: `src/db.js`
- [ ] **Step 1: Add the `events` table to the `db.exec` schema block**
In `src/db.js`, inside the existing `db.exec(\`...\`)` call (around line 2359, after the `bifrost_joins` block and before the closing backtick), append:
```sql
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type TEXT NOT NULL,
email TEXT NOT NULL,
occurred_at INTEGER NOT NULL,
session_id TEXT,
device_type TEXT,
os TEXT,
browser TEXT,
user_agent TEXT,
meta TEXT
);
CREATE INDEX IF NOT EXISTS idx_events_email ON events(email);
CREATE INDEX IF NOT EXISTS idx_events_type_time ON events(event_type, occurred_at);
```
- [ ] **Step 2: Add prepared statements to the exported `q` object**
In `src/db.js`, inside the `export const q = { ... }` block, before the `// cleanup` block (around line 175), add this section:
```js
// events — engagement tracking. One row per landmark event
// (login, timeline_view) with device fields parsed from the UA.
// Read-only from the app side; written once per event, never updated.
recordEvent: db.prepare(
`INSERT INTO events
(event_type, email, occurred_at, session_id, device_type, os, browser, user_agent, meta)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
),
listEvents: db.prepare(
`SELECT id, event_type, email, occurred_at, session_id, device_type, os, browser, user_agent, meta
FROM events ORDER BY occurred_at DESC LIMIT ?`
),
listEventsByType: db.prepare(
`SELECT id, event_type, email, occurred_at, session_id, device_type, os, browser, user_agent, meta
FROM events WHERE event_type = ? ORDER BY occurred_at DESC LIMIT ?`
),
listEventsForEmail: db.prepare(
`SELECT id, event_type, occurred_at, session_id, device_type, os, browser, meta
FROM events WHERE email = ? ORDER BY occurred_at DESC`
),
// Per-user summary: pivot login + timeline_view counts onto one row.
summariseEvents: db.prepare(
`SELECT email,
SUM(CASE WHEN event_type = 'login' THEN 1 ELSE 0 END) AS logins,
SUM(CASE WHEN event_type = 'timeline_view' THEN 1 ELSE 0 END) AS timeline_views,
MAX(occurred_at) AS last_seen
FROM events
GROUP BY email
ORDER BY last_seen DESC`
),
countEventsByType: db.prepare(
`SELECT event_type,
COUNT(*) AS total,
COUNT(DISTINCT email) AS unique_users
FROM events GROUP BY event_type ORDER BY event_type`
),
deviceBreakdown: db.prepare(
`SELECT device_type, COUNT(*) AS n
FROM events
WHERE device_type IS NOT NULL
GROUP BY device_type ORDER BY n DESC`
),
```
- [ ] **Step 3: Verify the server starts and the table exists**
Run:
```bash
npm run dev
```
Expected: `[bifrost] listening on 127.0.0.1:3000`. No errors. Stop the server (Ctrl-C) once you see the line.
Then verify the table was created:
```bash
node -e "import('./src/db.js').then(({default: db}) => { console.log(db.prepare(\"SELECT name FROM sqlite_master WHERE type='table' AND name='events'\").all()); console.log(db.prepare('PRAGMA table_info(events)').all()); process.exit(0); })"
```
Expected: one row `{ name: 'events' }` followed by 10 column rows (`id`, `event_type`, `email`, `occurred_at`, `session_id`, `device_type`, `os`, `browser`, `user_agent`, `meta`).
- [ ] **Step 4: Commit**
```bash
git add src/db.js
git commit -m "events: add events table and prepared statements"
```
---
## Task 2: CLI skeleton — `bin/events.js`
Build the CLI now (before the recorder) so later tasks have a verification tool.
**Files:**
- Create: `bin/events.js`
- [ ] **Step 1: Create `bin/events.js`**
```js
#!/usr/bin/env node
// ─────────────────────────────────────────────────────────────
// bin/events.js — read the engagement-event log.
//
// Records every landmark event (login, timeline_view) with the
// user's email, device fields parsed from the UA, and the session
// ID at time-of-event.
//
// Usage:
// node bin/events.js list [--type <event>] [--limit <n>]
// node bin/events.js summary # per-user counts
// node bin/events.js for <email> # full history for one user
// node bin/events.js stats # totals + device breakdown
// ─────────────────────────────────────────────────────────────
import { q } from '../src/db.js';
const args = process.argv.slice(2);
const cmd = args[0];
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
function help() {
console.log('Usage:');
console.log(' events list [--type <event>] [--limit <n>]');
console.log(' events summary # per-user counts');
console.log(' events for <email> # event history for a user');
console.log(' events stats # totals + device breakdown');
process.exit(1);
}
function iso(t) { return new Date(t).toISOString(); }
function shortSid(s) { return s ? `[${s.slice(0, 8)}…]` : '[—]'; }
function parseFlag(name) {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : null;
}
function parseMeta(s) {
if (!s) return null;
try { return JSON.parse(s); } catch { return s; }
}
function metaCompact(m) {
if (!m) return '';
if (typeof m !== 'object') return String(m);
return Object.entries(m).map(([k, v]) => `${k}=${v}`).join(' ');
}
switch (cmd) {
case 'list': {
const type = parseFlag('--type');
const limit = Number(parseFlag('--limit') || 200);
const rows = type
? q.listEventsByType.all(type, limit)
: q.listEvents.all(limit);
if (rows.length === 0) { console.log('(no events yet)'); break; }
for (const r of rows) {
const dev = [r.device_type, r.os, r.browser].filter(Boolean).join('/') || '?';
const meta = metaCompact(parseMeta(r.meta));
console.log(
` ${iso(r.occurred_at)} ${r.event_type.padEnd(14)} ${r.email.padEnd(28)} ${dev.padEnd(24)} ${shortSid(r.session_id)} ${meta}`
);
}
console.log(`\n${rows.length} event${rows.length === 1 ? '' : 's'} shown.`);
break;
}
case 'summary': {
const rows = q.summariseEvents.all();
if (rows.length === 0) { console.log('(no events yet)'); break; }
console.log(' LOGINS TIMELINE LAST SEEN EMAIL');
for (const r of rows) {
const lg = String(r.logins).padStart(6);
const tv = String(r.timeline_views).padStart(8);
console.log(` ${lg} ${tv} ${iso(r.last_seen)} ${r.email}`);
}
console.log(`\n${rows.length} unique user${rows.length === 1 ? '' : 's'}.`);
break;
}
case 'for': {
const arg = args[1];
if (!arg || !EMAIL_RE.test(arg)) help();
const email = arg.trim().toLowerCase();
const rows = q.listEventsForEmail.all(email);
if (rows.length === 0) { console.log(`(no events for ${email})`); break; }
console.log(`Events for ${email}:`);
for (const r of rows) {
const dev = [r.device_type, r.os, r.browser].filter(Boolean).join('/') || '?';
const meta = metaCompact(parseMeta(r.meta));
console.log(` ${iso(r.occurred_at)} ${r.event_type.padEnd(14)} ${dev.padEnd(24)} ${shortSid(r.session_id)} ${meta}`);
}
console.log(`\n${rows.length} event${rows.length === 1 ? '' : 's'}.`);
break;
}
case 'stats': {
const byType = q.countEventsByType.all();
if (byType.length === 0) { console.log('(no events yet)'); break; }
console.log(' EVENT TYPE TOTAL UNIQUE USERS');
for (const r of byType) {
console.log(` ${r.event_type.padEnd(14)} ${String(r.total).padStart(5)} ${String(r.unique_users).padStart(12)}`);
}
const dev = q.deviceBreakdown.all();
if (dev.length > 0) {
console.log('\n DEVICE TYPE COUNT');
for (const r of dev) {
console.log(` ${r.device_type.padEnd(14)} ${String(r.n).padStart(5)}`);
}
}
break;
}
default:
help();
}
```
- [ ] **Step 2: Verify the CLI runs**
```bash
node bin/events.js list
```
Expected: `(no events yet)`.
```bash
node bin/events.js summary
```
Expected: `(no events yet)`.
```bash
node bin/events.js stats
```
Expected: `(no events yet)`.
```bash
node bin/events.js
```
Expected: usage help text, exit code 1.
- [ ] **Step 3: Commit**
```bash
git add bin/events.js
git commit -m "events: add bin/events.js CLI for reading the event log"
```
---
## Task 3: UA parser — `src/ua.js`
**Files:**
- Create: `src/ua.js`
- [ ] **Step 1: Create `src/ua.js`**
```js
// ─────────────────────────────────────────────────────────────
// src/ua.js — minimal User-Agent parser.
//
// Coarse-grained classification only: device_type / os / browser.
// We deliberately do NOT pull in ua-parser-js — the project keeps a
// small dependency footprint and we only need three buckets. The raw
// UA is also stored alongside parsed fields (see src/events.js) so a
// regex miss can be re-classified later.
//
// Also owns MOBILE_UA_RE — the existing /timeline view-dispatch
// regex used by server.js. Single source of truth.
// ─────────────────────────────────────────────────────────────
// UA substrings that mean "phone-class small screen". Tablets (iPad,
// Android tablets) deliberately do NOT match — they get the desktop
// view, which matches existing behaviour in server.js.
export const MOBILE_UA_RE =
/\b(iPhone|iPod|Android.*Mobile|Mobile.*Firefox|IEMobile|BlackBerry|Opera Mini)\b/i;
// Tablet-class devices. Order matters in parseUA(): tablet check runs
// before mobile so "iPad" doesn't accidentally fall through to desktop.
const TABLET_UA_RE = /\b(iPad|Android(?!.*Mobile))\b/i;
export function parseUA(ua) {
if (!ua || typeof ua !== 'string') {
return { device_type: null, os: null, browser: null };
}
// device_type
let device_type = 'desktop';
if (TABLET_UA_RE.test(ua)) device_type = 'tablet';
else if (MOBILE_UA_RE.test(ua)) device_type = 'mobile';
// os
let os = 'other';
if (/\b(iPhone|iPad|iPod)\b/.test(ua)) os = 'iOS';
else if (/\bAndroid\b/.test(ua)) os = 'Android';
else if (/\bWindows\b/.test(ua)) os = 'Windows';
else if (/Mac OS X|Macintosh/.test(ua)) os = 'macOS';
else if (/\bLinux\b/.test(ua)) os = 'Linux';
// browser — order matters. All Chromium UAs include "Safari/", all
// Edge UAs include "Chrome/", so the most specific token must win.
let browser = 'other';
if (/\bEdg\//.test(ua)) browser = 'Edge';
else if (/\bFirefox\//.test(ua)) browser = 'Firefox';
else if (/\bChrome\//.test(ua)) browser = 'Chrome';
else if (/\bSafari\//.test(ua)) browser = 'Safari';
return { device_type, os, browser };
}
```
- [ ] **Step 2: Verify with a Node one-liner against known UA samples**
```bash
node -e "import('./src/ua.js').then(({parseUA}) => {
const samples = [
['iphone safari', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'],
['android chrome', 'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36'],
['ipad safari', 'Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/604.1'],
['mac chrome', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'],
['win edge', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0'],
['linux firefox', 'Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0'],
['empty', '']
];
for (const [label, ua] of samples) {
console.log(label.padEnd(16), parseUA(ua));
}
});"
```
Expected output:
```
iphone safari { device_type: 'mobile', os: 'iOS', browser: 'Safari' }
android chrome { device_type: 'mobile', os: 'Android', browser: 'Chrome' }
ipad safari { device_type: 'tablet', os: 'iOS', browser: 'Safari' }
mac chrome { device_type: 'desktop', os: 'macOS', browser: 'Chrome' }
win edge { device_type: 'desktop', os: 'Windows', browser: 'Edge' }
linux firefox { device_type: 'desktop', os: 'Linux', browser: 'Firefox' }
empty { device_type: null, os: null, browser: null }
```
If any row mismatches, fix the regex before committing.
- [ ] **Step 3: Commit**
```bash
git add src/ua.js
git commit -m "events: add UA parser (device_type/os/browser)"
```
---
## Task 4: Wire UA parser back into `server.js`
Replace the inline `MOBILE_UA_RE` declaration with an import from `src/ua.js`. Keeps a single source of truth.
**Files:**
- Modify: `server.js`
- [ ] **Step 1: Import `MOBILE_UA_RE` from `src/ua.js`**
In `server.js`, near the top imports (after the `q` import around line 15), add:
```js
import { MOBILE_UA_RE } from './src/ua.js';
```
- [ ] **Step 2: Delete the inline declaration**
In `server.js`, find and remove this line (currently around line 185):
```js
const MOBILE_UA_RE = /\b(iPhone|iPod|Android.*Mobile|Mobile.*Firefox|IEMobile|BlackBerry|Opera Mini)\b/i;
```
Leave the surrounding comment block intact (it documents `wantsMobileView()`); just the `const` line goes.
- [ ] **Step 3: Verify the server still starts and the dispatch still works**
Start the server:
```bash
npm run dev
```
In another terminal (still need a session cookie — assume one already exists in `cookies.txt` from prior testing, or create one with `node bin/invite.js add yourtest@example.com Test` then `curl -X POST http://127.0.0.1:3000/auth/login -H 'Content-Type: application/json' -d '{"email":"yourtest@example.com"}' -c cookies.txt`):
```bash
# desktop UA → desktop page
curl -s -o /dev/null -w "%{http_code}\n" -b cookies.txt \
-A 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' \
http://127.0.0.1:3000/timeline
# iphone UA → mobile page (different file)
curl -s -b cookies.txt \
-A 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Safari/604.1' \
http://127.0.0.1:3000/timeline | grep -o 'protected/mobile\|fenja-wordmark' | head -1
```
Expected:
- First curl: `200`
- Second curl: should output a string that demonstrates the mobile page was served (e.g. a CSS class or asset path unique to mobile). If `grep` finds nothing, also try `head -c 200` to inspect the first bytes — the mobile page differs from the desktop one. The exact marker doesn't matter; what matters is that the two requests return *different* HTML.
A simpler equivalence check:
```bash
diff <(curl -s -b cookies.txt -A 'Mozilla/5.0' http://127.0.0.1:3000/timeline) \
<(curl -s -b cookies.txt -A 'iPhone' http://127.0.0.1:3000/timeline) | head -3
```
Expected: non-empty diff (the two pages differ).
Stop the server.
- [ ] **Step 4: Commit**
```bash
git add server.js
git commit -m "events: source MOBILE_UA_RE from src/ua.js"
```
---
## Task 5: Event recorder — `src/events.js`
**Files:**
- Create: `src/events.js`
- [ ] **Step 1: Create `src/events.js`**
```js
// ─────────────────────────────────────────────────────────────
// src/events.js — landmark engagement event recorder.
//
// One function: recordEvent(req, {type, email, sessionId, meta}).
// Pulls the UA off the request, parses to {device_type, os, browser},
// and inserts a row into the `events` table (see src/db.js).
//
// Synchronous — better-sqlite3 is sync and the volume on this site
// is too low to justify any queueing or try/catch. If a future event
// becomes hot-path or recording becomes a failure mode, revisit.
//
// `sessionId` is passed in explicitly (rather than read from
// req.cookies) because the `login` event happens before req.cookies
// reflects the freshly-issued session cookie.
// ─────────────────────────────────────────────────────────────
import { q } from './db.js';
import { parseUA } from './ua.js';
export function recordEvent(req, { type, email, sessionId, meta = null }) {
const ua = req.headers['user-agent'] || '';
const { device_type, os, browser } = parseUA(ua);
q.recordEvent.run(
type,
email,
Date.now(),
sessionId || null,
device_type,
os,
browser,
ua || null,
meta ? JSON.stringify(meta) : null
);
}
```
- [ ] **Step 2: Verify the module imports cleanly**
```bash
node -e "import('./src/events.js').then(m => console.log(typeof m.recordEvent === 'function' ? 'ok' : 'missing'))"
```
Expected: `ok`.
- [ ] **Step 3: Commit**
```bash
git add src/events.js
git commit -m "events: add recordEvent helper"
```
---
## Task 6: `issueSession()` returns the new session ID
**Files:**
- Modify: `src/sessions.js`
- [ ] **Step 1: Return `id` from `issueSession()`**
In `src/sessions.js`, find `issueSession` (currently around lines 1938). At the end of the function body, after `res.cookie(...)`, add:
```js
return id;
```
The full function should now read:
```js
export function issueSession(req, res, email) {
const id = randomSessionId();
const now = Date.now();
q.createSession.run(
id,
email,
now,
now + SESSION_TTL_MS,
req.ip || null,
req.get('user-agent')?.slice(0, 500) || null
);
res.cookie(COOKIE_NAME, id, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: SESSION_TTL_MS,
});
return id;
}
```
- [ ] **Step 2: Verify nothing else breaks**
The only caller is `src/auth.js:54` (`issueSession(req, res, email)`), which currently ignores any return value. Verify with a grep:
```bash
node -e "console.log(require('child_process').execSync('grep -rn issueSession src/ server.js bin/ admin/').toString())"
```
(Or use Grep tool: pattern `issueSession`, path `.`.)
Expected: only two hits — the export in `src/sessions.js` and the call in `src/auth.js`. Adding a return value is non-breaking for the existing call site.
Then start the server to confirm it boots:
```bash
npm run dev
```
Expected: `[bifrost] listening on 127.0.0.1:3000`. Stop with Ctrl-C.
- [ ] **Step 3: Commit**
```bash
git add src/sessions.js
git commit -m "sessions: issueSession returns the new session id"
```
---
## Task 7: Record `login` event in `src/auth.js`
**Files:**
- Modify: `src/auth.js`
- [ ] **Step 1: Import `recordEvent`**
In `src/auth.js`, add to the imports near the top (after the existing imports around line 1619):
```js
import { recordEvent } from './events.js';
```
- [ ] **Step 2: Capture the new session ID and record the event**
In `src/auth.js`, find the success branch of `POST /auth/login` (currently lines 5359):
```js
issueSession(req, res, email);
return res.status(200).json({
ok: true,
firstName: invited.first_name || null,
});
```
Replace with:
```js
const sessionId = issueSession(req, res, email);
recordEvent(req, { type: 'login', email, sessionId });
return res.status(200).json({
ok: true,
firstName: invited.first_name || null,
});
```
- [ ] **Step 3: Verify a login writes a `login` row**
Start the server:
```bash
npm run dev
```
In another terminal — first ensure an invite exists (skip if you already have one):
```bash
node bin/invite.js add tracktest@example.com Track
```
Log in via curl:
```bash
curl -i -X POST http://127.0.0.1:3000/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"tracktest@example.com"}' \
-c cookies.txt
```
Expected: `HTTP/1.1 200 OK`, body `{"ok":true,"firstName":"Track"}`.
Then read the events log:
```bash
node bin/events.js list
```
Expected: one row with `event_type=login`, your email, current timestamp, device fields populated from curl's UA (curl's UA usually parses to `desktop / other / other`), session ID present (8-char prefix shown).
```bash
node bin/events.js summary
```
Expected: one row, `LOGINS=1, TIMELINE=0`.
Stop the server.
- [ ] **Step 4: Commit**
```bash
git add src/auth.js
git commit -m "events: record login event on POST /auth/login success"
```
---
## Task 8: Record `timeline_view` event in `server.js`
**Files:**
- Modify: `server.js`
- [ ] **Step 1: Import `recordEvent`**
In `server.js`, add to the imports near the top (after the `MOBILE_UA_RE` import added in Task 4):
```js
import { recordEvent } from './src/events.js';
```
- [ ] **Step 2: Record the event in the `/timeline` handler**
In `server.js`, find the `/timeline` route (currently lines 193198):
```js
app.get('/timeline', requireAuth, (req, res) => {
if (wantsMobileView(req)) {
return res.sendFile(path.join(__dirname, 'protected', 'mobile', 'index.html'));
}
return res.sendFile(path.join(__dirname, 'protected', 'index.html'));
});
```
Replace with:
```js
app.get('/timeline', requireAuth, (req, res) => {
const forced = ['mobile', 'desktop'].includes((req.query.view || '').toLowerCase());
const view = wantsMobileView(req) ? 'mobile' : 'desktop';
recordEvent(req, {
type: 'timeline_view',
email: req.session.email,
sessionId: req.session.id,
meta: { view, forced },
});
if (view === 'mobile') {
return res.sendFile(path.join(__dirname, 'protected', 'mobile', 'index.html'));
}
return res.sendFile(path.join(__dirname, 'protected', 'index.html'));
});
```
The `forced` boolean is derived BEFORE `wantsMobileView()` collapses the query and UA into a single answer, so we record whether the user explicitly overrode the UA guess.
- [ ] **Step 3: Verify a timeline visit writes a `timeline_view` row**
Start the server (assumes you still have `cookies.txt` from Task 7):
```bash
npm run dev
```
```bash
# desktop UA, no override
curl -s -o /dev/null -b cookies.txt \
-A 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' \
http://127.0.0.1:3000/timeline
# desktop UA, forced mobile via ?view=mobile
curl -s -o /dev/null -b cookies.txt \
-A 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' \
'http://127.0.0.1:3000/timeline?view=mobile'
# iphone UA, no override
curl -s -o /dev/null -b cookies.txt \
-A 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 Safari/604.1' \
http://127.0.0.1:3000/timeline
```
Then:
```bash
node bin/events.js list --type timeline_view
```
Expected: three rows, newest first:
- `timeline_view tracktest@example.com mobile/iOS/Safari [...] view=mobile forced=false`
- `timeline_view tracktest@example.com desktop/macOS/Chrome [...] view=mobile forced=true`
- `timeline_view tracktest@example.com desktop/macOS/Chrome [...] view=desktop forced=false`
```bash
node bin/events.js stats
```
Expected: `login total=1`, `timeline_view total=3`, device breakdown shows both `desktop` and `mobile`.
Stop the server.
- [ ] **Step 4: Commit**
```bash
git add server.js
git commit -m "events: record timeline_view on GET /timeline with view+forced meta"
```
---
## Task 9: Documentation
**Files:**
- Modify: `CLAUDE.md`
- Modify: `OPERATIONS.md`
- Modify: `CHECKLIST.md`
- [ ] **Step 1: Add `bin/events.js` to `CLAUDE.md` commands block**
In `CLAUDE.md`, find the `## Common commands` block. Below the existing `node bin/joins.js list` line (the one ending `# (also: summary, for <email>, stats)`), append:
```bash
node bin/events.js list # read engagement event log
# (also: summary, for <email>, stats)
```
- [ ] **Step 2: Add an "events" line to the bin/* description below the commands block**
In `CLAUDE.md`, find the line describing `bin/joins.js` (under "Conventions" near the bottom):
```
- `bin/invite.js` and `bin/joins.js` are the admin CLIs — there is no web UI for either by design. `invite.js` manages the invite list; `joins.js` reads the CTA click log.
```
Replace with:
```
- `bin/invite.js`, `bin/joins.js`, and `bin/events.js` are the admin CLIs — there is no web UI for them by design. `invite.js` manages the invite list; `joins.js` reads the final-CTA click log; `events.js` reads the engagement event log (logins, timeline views).
```
- [ ] **Step 3: Add a section to `OPERATIONS.md`**
In `OPERATIONS.md`, after the "Reading Join-CTA clicks" section (which ends around line 88 with a `sqlite3` example), and before the `## Service control` section, insert the literal text below. The outer 4-backtick fence is just so this plan can show triple-backtick content inside — paste only the inner content (everything between the lines marked `<<< begin paste >>>` and `<<< end paste >>>`).
````markdown
<<< begin paste >>>
## Reading engagement events
Logins and timeline page views are logged to the `events` table. Each row carries the user's email, a timestamp, the session ID, and device fields parsed from the User-Agent (`device_type`, `os`, `browser`). Use `bin/events.js` to read it:
```bash
# Every event, newest first (filter with --type, page with --limit)
sudo -u fenja node /opt/fenja/bin/events.js list
sudo -u fenja node /opt/fenja/bin/events.js list --type login --limit 50
# One row per user — login count, timeline-view count, last seen
sudo -u fenja node /opt/fenja/bin/events.js summary
# Full event history for a single user
sudo -u fenja node /opt/fenja/bin/events.js for someone@example.com
# Totals per event type + device-type breakdown
sudo -u fenja node /opt/fenja/bin/events.js stats
```
Events recorded:
- `login` — written on `POST /auth/login` success. One row per fresh login (cookie-loss re-logins included). The `meta` column is empty.
- `timeline_view` — written on every `GET /timeline`. `meta` is `{view: "mobile"|"desktop", forced: true|false}`; `forced=true` means the user passed `?view=mobile` or `?view=desktop` to override the UA guess.
For ad-hoc SQL:
```bash
sudo -u fenja sqlite3 /opt/fenja/data/fenja.sqlite \
"SELECT event_type, email, datetime(occurred_at/1000,'unixepoch'), device_type, os, browser FROM events ORDER BY occurred_at DESC LIMIT 50;"
```
<<< end paste >>>
````
- [ ] **Step 4: Add a CHECKLIST section**
In `CHECKLIST.md`, after the existing `## H1. After changes to the Join-CTA tracking (bifrost_joins)` section (which ends around line 111), and BEFORE the `## H2. After changes to the hidden admin page` section, insert:
```markdown
## H1b. After changes to engagement event tracking (events)
- [ ] [browser, logged out] Log in fresh via the entrance form → entrance advances to the welcome step
- [ ] `sudo -u fenja node /opt/fenja/bin/events.js list --type login --limit 5` shows a new row with your email, current timestamp, populated device/os/browser, and a session-ID prefix
- [ ] [browser, logged in] Visit `/timeline` → page loads
- [ ] `sudo -u fenja node /opt/fenja/bin/events.js list --type timeline_view --limit 5` shows a new row with `view=desktop forced=false` (or `view=mobile forced=false` if you tested on a phone UA)
- [ ] [browser] Visit `/timeline?view=mobile` from a desktop UA → mobile page renders
- [ ] `sudo -u fenja node /opt/fenja/bin/events.js list --type timeline_view --limit 5` shows the most recent row with `view=mobile forced=true`
- [ ] `sudo -u fenja node /opt/fenja/bin/events.js summary` includes your email with correct `LOGINS` and `TIMELINE` counts
- [ ] `sudo -u fenja node /opt/fenja/bin/events.js stats` totals match what `list` shows; device breakdown reflects the views you generated
- [ ] `sudo -u fenja node /opt/fenja/bin/events.js for <yourtestemail>` shows full per-user history
- [ ] No 500s in `journalctl -u fenja -n 100` from the test traffic
```
- [ ] **Step 5: Verify the docs render correctly**
Read each modified file back briefly:
```bash
node -e "console.log(require('fs').readFileSync('CLAUDE.md','utf8').match(/node bin\/events.js[^\n]*/g))"
node -e "console.log(require('fs').readFileSync('OPERATIONS.md','utf8').includes('Reading engagement events'))"
node -e "console.log(require('fs').readFileSync('CHECKLIST.md','utf8').includes('H1b. After changes to engagement event tracking'))"
```
Expected:
- First: array of two matches (`node bin/events.js list` and `node bin/events.js list --type ...` if it matches greedy).
- Second: `true`.
- Third: `true`.
- [ ] **Step 6: Commit**
```bash
git add CLAUDE.md OPERATIONS.md CHECKLIST.md
git commit -m "events: document bin/events.js in CLAUDE.md, OPERATIONS.md, CHECKLIST.md"
```
---
## Final manual verification (do once after all tasks land)
Walk the new H1b section in `CHECKLIST.md` end-to-end against a fresh local dev server. This catches any integration issue the per-task curl checks miss (cookie persistence across browser sessions, the `Secure` cookie flag toggling on `NODE_ENV=production`, etc.).
If anything in H1b fails, fix it BEFORE merging — and propagate the fix back into the relevant task's verification step in this plan so the same gap doesn't reappear next time.
## What's intentionally NOT in this plan
(See spec § "Out of scope" for full list.)
- Failed login tracking, logout tracking
- Scroll-depth, dwell time, per-section timeline views
- Folding `bifrost_joins` into the unified `events` table
- A web UI for viewing events
- Bot/crawler filtering (everything is invite-gated)
- Deploy/migration steps — `CREATE TABLE IF NOT EXISTS` runs on the next service restart per the existing `src/db.js` boot sequence; no separate migration step needed.