Add comprehensive visitor engagement tracking and scroll-to-top on navigation

Tracks scroll depth (25/50/75/90%), time on page, UTM parameters, outbound
link clicks, search queries, audio pause/completion/listen time, and 404s.
Logged-in study users are now tied to their visitor record and surfaced in
the admin recent visits table. Scroll position resets on every route change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmert
2026-06-17 14:06:21 -04:00
parent a6f0c39c35
commit 31426fccba
11 changed files with 498 additions and 4 deletions
+1
View File
@@ -38,6 +38,7 @@ export const STUDY_CERTIFICATES_FILE = path.join(DATA_DIR, 'study-certificates.j
export const EPISODE_SCRIPTS_FILE = path.join(DATA_DIR, 'episode-scripts.json')
export const QR_CODES_FILE = path.join(DATA_DIR, 'qr-codes.json')
export const EPISODE_PLAYS_FILE = path.join(DATA_DIR, 'episode-plays.json')
export const ANALYTICS_EVENTS_FILE = path.join(DATA_DIR, 'analytics-events.json')
export const MAX_EPISODE_SCRIPT_LENGTH = 200_000 // ~150k words, well beyond any sermon
export const DIST_DIR = path.join(ROOT_DIR, 'dist')
+91
View File
@@ -29,6 +29,7 @@ import {
UPLOADS_META_FILE,
DOWNLOAD_COUNTS_FILE,
EPISODE_PLAYS_FILE,
ANALYTICS_EVENTS_FILE,
EMPTY_HIT_STATS,
EMPTY_VISITOR_STATS,
DEFAULT_REPLY_TEMPLATES,
@@ -710,6 +711,96 @@ export function recordEpisodePlay(title) {
queueEpisodePlaysWrite()
}
// ── Analytics events ───────────────────────────────────────────────────────
const EMPTY_ANALYTICS_EVENTS = {
scrollDepth: {},
timeOnPage: {},
outboundClicks: {},
utmSources: {},
searchQueries: {},
notFound: {},
audioEvents: {},
}
export function queueAnalyticsEventsWrite() {
state.analyticsEventsWritePromise = state.analyticsEventsWritePromise
.then(async () => {
await mkdir(DATA_DIR, { recursive: true })
await writeFile(ANALYTICS_EVENTS_FILE, JSON.stringify(state.analyticsEvents, null, 2), 'utf8')
})
.catch(err => {
console.error('[analytics-events] failed to write:', err)
})
}
export function loadAnalyticsEventsFromDisk() {
return readFile(ANALYTICS_EVENTS_FILE, 'utf8')
.then(raw => {
const parsed = JSON.parse(raw)
state.analyticsEvents = {
scrollDepth: parsed?.scrollDepth ?? {},
timeOnPage: parsed?.timeOnPage ?? {},
outboundClicks: parsed?.outboundClicks ?? {},
utmSources: parsed?.utmSources ?? {},
searchQueries: parsed?.searchQueries ?? {},
notFound: parsed?.notFound ?? {},
audioEvents: parsed?.audioEvents ?? {},
}
})
.catch(() => {
state.analyticsEvents = { ...EMPTY_ANALYTICS_EVENTS }
})
}
export function recordAnalyticsEvent(type, data) {
const ev = state.analyticsEvents
if (type === 'scroll_depth') {
const path = data.path ?? '/'
if (!ev.scrollDepth[path]) ev.scrollDepth[path] = { 25: 0, 50: 0, 75: 0, 90: 0 }
const mark = String(data.depth)
ev.scrollDepth[path][mark] = (ev.scrollDepth[path][mark] ?? 0) + 1
} else if (type === 'time_on_page') {
const path = data.path ?? '/'
const seconds = Number(data.seconds) || 0
if (!ev.timeOnPage[path]) ev.timeOnPage[path] = { totalSeconds: 0, count: 0 }
ev.timeOnPage[path].totalSeconds += seconds
ev.timeOnPage[path].count += 1
} else if (type === 'outbound_click') {
const url = typeof data.url === 'string' ? data.url.slice(0, 500) : ''
if (url) ev.outboundClicks[url] = (ev.outboundClicks[url] ?? 0) + 1
} else if (type === 'utm') {
const source = typeof data.utm_source === 'string' ? data.utm_source.slice(0, 100) : 'unknown'
ev.utmSources[source] = (ev.utmSources[source] ?? 0) + 1
} else if (type === 'search_query') {
const q = typeof data.query === 'string' ? data.query.trim().slice(0, 200) : ''
if (q) ev.searchQueries[q] = (ev.searchQueries[q] ?? 0) + 1
} else if (type === 'not_found') {
const path = typeof data.path === 'string' ? data.path.slice(0, 300) : '/'
ev.notFound[path] = (ev.notFound[path] ?? 0) + 1
} else if (type === 'audio_pause') {
const title = typeof data.title === 'string' ? data.title.trim().slice(0, 200) : ''
if (title) {
if (!ev.audioEvents[title]) ev.audioEvents[title] = { pauses: 0, completions: 0, totalListenSeconds: 0 }
ev.audioEvents[title].pauses += 1
}
} else if (type === 'audio_completion') {
const title = typeof data.title === 'string' ? data.title.trim().slice(0, 200) : ''
if (title) {
if (!ev.audioEvents[title]) ev.audioEvents[title] = { pauses: 0, completions: 0, totalListenSeconds: 0 }
ev.audioEvents[title].completions += 1
}
} else if (type === 'audio_listen_time') {
const title = typeof data.title === 'string' ? data.title.trim().slice(0, 200) : ''
const seconds = Number(data.seconds) || 0
if (title && seconds > 0) {
if (!ev.audioEvents[title]) ev.audioEvents[title] = { pauses: 0, completions: 0, totalListenSeconds: 0 }
ev.audioEvents[title].totalListenSeconds += seconds
}
}
queueAnalyticsEventsWrite()
}
// ── Uploads ────────────────────────────────────────────────────────────────
export async function readUploadsMetadata() {
+54 -1
View File
@@ -1,12 +1,13 @@
import { createHash, randomUUID } from 'node:crypto'
import { requireAdminAuth, isValidAdminSession } from '../auth.js'
import { getClientIp, hasVisitorConsent, setConsentCookie, parseCookies } from '../helpers.js'
import { getStudyUserFromRequest } from '../study-helpers.js'
import {
VISITOR_COOKIE,
MAX_RECENT_VISITS,
} from '../config.js'
import { state } from '../state.js'
import { queueVisitorStatsWrite, queueHitStatsWrite, normalizeMessageType, recordEpisodePlay } from '../data.js'
import { queueVisitorStatsWrite, queueHitStatsWrite, normalizeMessageType, recordEpisodePlay, recordAnalyticsEvent } from '../data.js'
import {
detectBot,
sanitizeUserAgent,
@@ -88,6 +89,10 @@ export async function recordVisitor(req, res, overridePath = null, overrideRefer
const ip = getClientIp(req)
const ua = sanitizeUserAgent(req.get('user-agent'))
const device = detectDevice(ua)
const studyUser = getStudyUserFromRequest(req)
const studyIdentity = studyUser
? { userId: studyUser.id, username: studyUser.username, displayName: studyUser.displayName ?? studyUser.username }
: null
const ipHash = createHash('sha256').update(ip).digest('hex')
const geo = await resolveGeo(ip)
@@ -113,6 +118,11 @@ export async function recordVisitor(req, res, overridePath = null, overrideRefer
const prevHistory = existingVisitor?.pageHistory ?? []
const pageHistory = [...prevHistory, { at: nowIso, path: pathKey, referrer }].slice(-100)
const knownIdentities = existingVisitor?.knownIdentities ?? []
if (studyIdentity && !knownIdentities.some(i => i.userId === studyIdentity.userId)) {
knownIdentities.push(studyIdentity)
}
state.visitorStats.visitors[visitorId] = {
visitorId, ip, ipHash,
firstSeenAt: existingVisitor?.firstSeenAt ?? nowIso,
@@ -124,6 +134,7 @@ export async function recordVisitor(req, res, overridePath = null, overrideRefer
userAgents,
device,
pageHistory,
...(knownIdentities.length > 0 ? { knownIdentities } : {}),
}
state.visitorStats.totalVisits += 1
@@ -133,6 +144,7 @@ export async function recordVisitor(req, res, overridePath = null, overrideRefer
at: nowIso, visitorId, ip, path: pathKey, referrer, device,
country: geo.country, state: geo.state, county: geo.county, city: geo.city,
returningVisitor: isReturning, visitCount: nextVisitCount,
...(studyIdentity ? { studyUser: studyIdentity } : {}),
})
state.visitorStats.recentVisits = state.visitorStats.recentVisits.slice(0, MAX_RECENT_VISITS)
@@ -154,6 +166,19 @@ export function register(app) {
res.json({ ok: true })
})
app.post('/api/analytics/event', (req, res) => {
if (isValidAdminSession(req)) { res.json({ ok: false, reason: 'admin' }); return }
if (!hasVisitorConsent(req)) { res.json({ ok: false, reason: 'no-consent' }); return }
const ua = req.get('user-agent') ?? ''
const { isBot } = detectBot(ua)
if (isBot) { res.json({ ok: false, reason: 'bot' }); return }
const type = typeof req.body?.type === 'string' ? req.body.type : ''
const ALLOWED_TYPES = ['scroll_depth', 'time_on_page', 'outbound_click', 'utm', 'search_query', 'not_found', 'audio_pause', 'audio_completion', 'audio_listen_time']
if (!ALLOWED_TYPES.includes(type)) { res.status(400).json({ ok: false, reason: 'invalid-type' }); return }
recordAnalyticsEvent(type, req.body)
res.json({ ok: true })
})
app.post('/api/analytics/pageview', async (req, res) => {
if (isValidAdminSession(req)) {
res.json({ ok: false, reason: 'admin' }); return
@@ -340,6 +365,34 @@ export function register(app) {
last30Days: buildLastNDaysStats(30).map(item => ({ day: item.day, plays: data.byDay?.[item.day] ?? 0 })),
}))
.sort((a, b) => b.total - a.total),
engagement: {
scrollDepth: Object.entries(state.analyticsEvents.scrollDepth ?? {})
.map(([path, marks]) => ({ path, ...marks }))
.sort((a, b) => (b[90] ?? 0) - (a[90] ?? 0))
.slice(0, 20),
timeOnPage: Object.entries(state.analyticsEvents.timeOnPage ?? {})
.map(([path, { totalSeconds, count }]) => ({ path, avgSeconds: count > 0 ? Math.round(totalSeconds / count) : 0, count }))
.sort((a, b) => b.avgSeconds - a.avgSeconds)
.slice(0, 20),
topOutboundClicks: Object.entries(state.analyticsEvents.outboundClicks ?? {})
.map(([url, count]) => ({ url, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 20),
topUTMSources: Object.entries(state.analyticsEvents.utmSources ?? {})
.map(([source, count]) => ({ source, count }))
.sort((a, b) => b.count - a.count),
topSearchQueries: Object.entries(state.analyticsEvents.searchQueries ?? {})
.map(([query, count]) => ({ query, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 30),
top404s: Object.entries(state.analyticsEvents.notFound ?? {})
.map(([path, count]) => ({ path, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 20),
audioEvents: Object.entries(state.analyticsEvents.audioEvents ?? {})
.map(([title, data]) => ({ title, ...data }))
.sort((a, b) => b.completions - a.completions),
},
})
})
}
+21
View File
@@ -63,6 +63,27 @@ export const state = {
episodePlays: {},
episodePlaysWritePromise: Promise.resolve(),
// analyticsEvents: aggregated engagement data from frontend events
// {
// scrollDepth: { [path]: { 25: n, 50: n, 75: n, 90: n } }
// timeOnPage: { [path]: { totalSeconds: n, count: n } }
// outboundClicks: { [url]: n }
// utmSources: { [utm_source]: n }
// searchQueries: { [query]: n }
// notFound: { [path]: n }
// audioEvents: { [title]: { pauses: n, completions: n, totalListenSeconds: n } }
// }
analyticsEvents: {
scrollDepth: {},
timeOnPage: {},
outboundClicks: {},
utmSources: {},
searchQueries: {},
notFound: {},
audioEvents: {},
},
analyticsEventsWritePromise: Promise.resolve(),
// qrCodes: Array<{ id, slug, label, destination, createdAt }>
// qrScans: Array<{ id, qrId, slug, scannedAt, ip, userAgent }>
qrCodes: [],