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
71 lines
3.2 KiB
JavaScript
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 });
|
|
});
|
|
});
|