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

32 KiB
Raw Permalink Blame History

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

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.jsrecordEvent() 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.jsissueSession() 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 thebifrost_joins` block and before the closing backtick), append:


  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:

  // 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:

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:

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

#!/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
node bin/events.js list

Expected: (no events yet).

node bin/events.js summary

Expected: (no events yet).

node bin/events.js stats

Expected: (no events yet).

node bin/events.js

Expected: usage help text, exit code 1.

  • Step 3: Commit
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

// ─────────────────────────────────────────────────────────────
// 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
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
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:

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):

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:

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):

# 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:

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

// ─────────────────────────────────────────────────────────────
// 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
node -e "import('./src/events.js').then(m => console.log(typeof m.recordEvent === 'function' ? 'ok' : 'missing'))"

Expected: ok.

  • Step 3: Commit
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:

  return id;

The full function should now read:

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:

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:

npm run dev

Expected: [bifrost] listening on 127.0.0.1:3000. Stop with Ctrl-C.

  • Step 3: Commit
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):

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):

    issueSession(req, res, email);
    return res.status(200).json({
      ok: true,
      firstName: invited.first_name || null,
    });

Replace with:

    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:

npm run dev

In another terminal — first ensure an invite exists (skip if you already have one):

node bin/invite.js add tracktest@example.com Track

Log in via curl:

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:

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).

node bin/events.js summary

Expected: one row, LOGINS=1, TIMELINE=0.

Stop the server.

  • Step 4: Commit
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):

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):

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:

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):

npm run dev
# 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:

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
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
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:

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 >>>).

<<< 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:

## 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:

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

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.