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>
This commit is contained in:
parent
4d2ff042e3
commit
44f7a8c5d7
1 changed files with 928 additions and 0 deletions
928
docs/superpowers/plans/2026-04-27-engagement-tracking.md
Normal file
928
docs/superpowers/plans/2026-04-27-engagement-tracking.md
Normal file
|
|
@ -0,0 +1,928 @@
|
||||||
|
# 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 7–8). 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 23–59, 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 19–38). 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 16–19):
|
||||||
|
|
||||||
|
```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 53–59):
|
||||||
|
|
||||||
|
```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 193–198):
|
||||||
|
|
||||||
|
```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.
|
||||||
Loading…
Add table
Reference in a new issue