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:
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user