Files
hope-events/backend/tests/analytics.test.js
T
joshuaandClaude Opus 5.5 cc62f52ab3 Add registration funnel stats
First-party, consent-based funnel tracking: a cookie notice asks once,
and only visitors who allow it get an anonymous visit id. Event page
views and registration starts are tracked from the browser; submit and
paid come from the registration. Adds the Registration funnel report
(conversion per stage, daily trend, by source/device/ticket option), a
conversion tile on the supervisor/admin dashboards, short-link source
tagging, and a daily rollup of raw rows older than 90 days.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP62L9JxHomA36aYP1ekCe
2026-10-19 18:14:18 +02:00

71 lines
3.2 KiB
JavaScript

const { deviceFromUA, cleanSource, sessionsFrom, rollupRows, funnelReport } = require('../src/utils/analytics');
const ev = (sessionId, stage, day, extra = {}) => ({ sessionId, stage, createdAt: `${day}T10:00:00Z`, ...extra });
describe('deviceFromUA', () => {
test('mobile, tablet, desktop and bots', () => {
expect(deviceFromUA('Mozilla/5.0 (iPhone; CPU iPhone OS 17_0) Mobile/15E148')).toBe('mobile');
expect(deviceFromUA('Mozilla/5.0 (Linux; Android 14; SM-X200) Safari')).toBe('tablet');
expect(deviceFromUA('Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120')).toBe('desktop');
expect(deviceFromUA('Googlebot/2.1')).toBeNull();
expect(deviceFromUA('')).toBeNull();
});
});
describe('cleanSource', () => {
test('tidies, defaulting to direct', () => {
expect(cleanSource(' Facebook ')).toBe('facebook');
expect(cleanSource('camp-2025<script>')).toBe('camp-2025script');
expect(cleanSource('')).toBe('direct');
});
});
describe('sessionsFrom', () => {
test('a session counts as having reached every earlier stage', () => {
const [s] = sessionsFrom([ev('a', 'start', '2026-01-02')]);
expect(s.reach).toBe(1);
expect(s.days.view).toBe('2026-01-02');
});
test('paid only counts for sessions that submitted', () => {
const out = sessionsFrom([ev('a', 'view', '2026-01-01', { source: 'fb', device: 'mobile' }), ev('a', 'submit', '2026-01-03'), ev('b', 'view', '2026-01-01')], new Set(['a', 'b']));
const a = out.find(s => s.sessionId === 'a');
expect(a.reach).toBe(3);
expect(a.days.paid).toBe('2026-01-03');
expect(a.source).toBe('fb');
expect(out.find(s => s.sessionId === 'b').reach).toBe(0);
});
});
describe('funnelReport', () => {
const sessions = sessionsFrom([
ev('a', 'view', '2026-01-01', { source: 'fb', device: 'mobile' }), ev('a', 'start', '2026-01-01'), ev('a', 'submit', '2026-01-01'),
ev('b', 'view', '2026-01-01', { source: 'fb', device: 'desktop' }), ev('b', 'start', '2026-01-02'),
ev('c', 'view', '2026-01-02', { device: 'mobile' }),
ev('d', 'view', '2026-01-02', { device: 'mobile' }),
], new Set(['a']));
test('counts and conversion per stage', () => {
const r = funnelReport(sessions);
expect(r.totals).toEqual({ view: 4, start: 2, submit: 1, paid: 1 });
expect(r.conversion.start).toBe(50);
expect(r.conversion.paid).toBe(25);
expect(r.conversion.startToSubmit).toBe(50);
});
test('daily trend and breakdowns', () => {
const r = funnelReport(sessions);
expect(r.trend).toEqual([{ day: '2026-01-01', view: 2, start: 1, submit: 1, paid: 1 }, { day: '2026-01-02', view: 2, start: 1, submit: 0, paid: 0 }]);
expect(r.bySource.find(s => s.key === 'fb')).toMatchObject({ view: 2, paid: 1, conversion: 50 });
expect(r.bySource.find(s => s.key === 'direct').view).toBe(2);
expect(r.byDevice.find(s => s.key === 'mobile').view).toBe(3);
});
test('rolled-up rows add to the raw counts', () => {
const daily = rollupRows(sessionsFrom([ev('x', 'view', '2025-10-01'), ev('x', 'submit', '2025-10-01')], new Set(['x'])));
expect(daily.find(r => r.stage === 'paid').sessions).toBe(1);
const r = funnelReport(sessions, daily);
expect(r.totals).toEqual({ view: 5, start: 3, submit: 2, paid: 2 });
});
});