Analytics overhaul: privacy fixes, All Time range, readability improvements; v2.0.29
Publish Container / docker (push) Successful in 14m17s

- Remove raw IP storage; store only SHA-256 hash prefix in visitor records
- Non-blocking geo lookup: recordVisitor now synchronous, geo resolved in background
- UTM capture race condition fixed via sessionStorage pre-consent capture
- Add consent + bot checks to play endpoint (was missing)
- Add rate limiter to pageview endpoint (60 req/min per IP)
- Add 'All time' range option alongside 7d/30d/90d with trendData aggregation
- AnalyticsPanel: tabbed layout (Traffic/Engagement/System), dynamic chart titles,
  range buttons with active state, visitor log uses ipHash instead of raw IP,
  trendData drives main chart with weekly bucketing for all-time view
- Re-add prop-types dependency (required by react-simple-maps bundler)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-09-17 16:27:53 -04:00
1 parent da5c1c5078
commit 4002f1ee93
6 files changed
+333 -231

No files matched your search

+39 -1
View File
@@ -21,6 +21,7 @@
"mammoth": "^1.12.0", "mammoth": "^1.12.0",
"multer": "^2.3.0", "multer": "^2.3.0",
"otplib": "^13.4.0", "otplib": "^13.4.0",
"prop-types": "^15.8.1",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "^19.2.4", "react": "^19.2.4",
"react-chartjs-2": "^5.2.0", "react-chartjs-2": "^5.2.0",
@@ -6898,7 +6899,6 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
@@ -7346,6 +7346,18 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/lop": { "node_modules/lop": {
"version": "0.4.2", "version": "0.4.2",
"resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz", "resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz",
@@ -8470,6 +8482,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-inspect": { "node_modules/object-inspect": {
"version": "1.13.4", "version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@ -8873,6 +8894,17 @@
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/property-information": { "node_modules/property-information": {
"version": "7.1.0", "version": "7.1.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
@@ -9250,6 +9282,12 @@
"react": "^19.2.4" "react": "^19.2.4"
} }
}, },
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-markdown": { "node_modules/react-markdown": {
"version": "10.1.0", "version": "10.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
+2 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "siteforge", "name": "siteforge",
"private": true, "private": true,
"version": "2.0.27", "version": "2.0.29",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -26,6 +26,7 @@
"mammoth": "^1.12.0", "mammoth": "^1.12.0",
"multer": "^2.3.0", "multer": "^2.3.0",
"otplib": "^13.4.0", "otplib": "^13.4.0",
"prop-types": "^15.8.1",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "^19.2.4", "react": "^19.2.4",
"react-chartjs-2": "^5.2.0", "react-chartjs-2": "^5.2.0",
+90 -44
View File
@@ -22,12 +22,12 @@ import {
getStudyCatalog, getStudyCatalog,
} from '../study-helpers.js' } from '../study-helpers.js'
async function resolveGeo(ip) { async function resolveGeo(ip, ipHash) {
if (!ip || isPrivateOrLocalIp(ip)) { if (!ip || isPrivateOrLocalIp(ip)) {
return { country: 'Local/Unknown', state: 'Local/Unknown', county: 'Local/Unknown', city: 'Local/Unknown' } return { country: 'Local/Unknown', state: 'Local/Unknown', county: 'Local/Unknown', city: 'Local/Unknown' }
} }
const cached = state.visitorStats.geoCacheByIp[ip] const cached = state.visitorStats.geoCacheByIp[ipHash]
if (cached) return cached if (cached) return cached
const providers = [ const providers = [
@@ -62,7 +62,7 @@ async function resolveGeo(ip) {
try { try {
const geo = await provider() const geo = await provider()
if (geo) { if (geo) {
state.visitorStats.geoCacheByIp[ip] = geo state.visitorStats.geoCacheByIp[ipHash] = geo
queueVisitorStatsWrite() queueVisitorStatsWrite()
return geo return geo
} }
@@ -70,17 +70,33 @@ async function resolveGeo(ip) {
} }
const fallback = { country: 'Unknown', state: 'Unknown', county: 'Unknown', city: 'Unknown' } const fallback = { country: 'Unknown', state: 'Unknown', county: 'Unknown', city: 'Unknown' }
state.visitorStats.geoCacheByIp[ip] = fallback state.visitorStats.geoCacheByIp[ipHash] = fallback
queueVisitorStatsWrite() queueVisitorStatsWrite()
return fallback return fallback
} }
export async function recordVisitor(req, res, overridePath = null, overrideReferrer = null) { // Simple in-memory rate limiter for pageview endpoint
const pageviewHits = new Map()
function pageviewAllowed(ip) {
const now = Date.now()
const windowMs = 60 * 1000
const entry = pageviewHits.get(ip) ?? { count: 0, start: now }
if (now - entry.start > windowMs) { entry.count = 0; entry.start = now }
entry.count += 1
pageviewHits.set(ip, entry)
if (pageviewHits.size > 5000) {
const cutoff = now - windowMs
for (const [k, v] of pageviewHits) { if (v.start < cutoff) pageviewHits.delete(k) }
}
return entry.count <= 60
}
export function recordVisitor(req, res, overridePath = null, overrideReferrer = null) {
const cookies = parseCookies(req.headers.cookie) const cookies = parseCookies(req.headers.cookie)
let visitorId = cookies[VISITOR_COOKIE] let visitorId = cookies[VISITOR_COOKIE]
if (!visitorId) { if (!visitorId) {
visitorId = randomUUID() visitorId = randomUUID()
res.append('Set-Cookie', `${VISITOR_COOKIE}=${encodeURIComponent(visitorId)}; Max-Age=31536000; Path=/; SameSite=Lax`) res.append('Set-Cookie', `${VISITOR_COOKIE}=${encodeURIComponent(visitorId)}; Max-Age=31536000; Path=/; SameSite=Lax; HttpOnly`)
} }
const nowIso = new Date().toISOString() const nowIso = new Date().toISOString()
@@ -95,12 +111,11 @@ export async function recordVisitor(req, res, overridePath = null, overrideRefer
: null : null
const ipHash = createHash('sha256').update(ip).digest('hex') const ipHash = createHash('sha256').update(ip).digest('hex')
const geo = await resolveGeo(ip)
const existingIdByIp = state.visitorStats.ipHashIndex[ipHash] const existingIdByIp = state.visitorStats.ipHashIndex[ipHash]
if (existingIdByIp && existingIdByIp !== visitorId) { if (existingIdByIp && existingIdByIp !== visitorId) {
visitorId = existingIdByIp visitorId = existingIdByIp
res.append('Set-Cookie', `${VISITOR_COOKIE}=${encodeURIComponent(visitorId)}; Max-Age=31536000; Path=/; SameSite=Lax`) res.append('Set-Cookie', `${VISITOR_COOKIE}=${encodeURIComponent(visitorId)}; Max-Age=31536000; Path=/; SameSite=Lax; HttpOnly`)
} }
const existingVisitor = state.visitorStats.visitors[visitorId] const existingVisitor = state.visitorStats.visitors[visitorId]
@@ -123,8 +138,12 @@ export async function recordVisitor(req, res, overridePath = null, overrideRefer
knownIdentities.push(studyIdentity) knownIdentities.push(studyIdentity)
} }
// Use cached geo immediately; will be resolved async if not yet cached
const cachedGeo = state.visitorStats.geoCacheByIp[ipHash]
const geo = cachedGeo ?? { country: 'Unknown', state: 'Unknown', county: 'Unknown', city: 'Unknown' }
state.visitorStats.visitors[visitorId] = { state.visitorStats.visitors[visitorId] = {
visitorId, ip, ipHash, visitorId, ipHash,
firstSeenAt: existingVisitor?.firstSeenAt ?? nowIso, firstSeenAt: existingVisitor?.firstSeenAt ?? nowIso,
lastSeenAt: nowIso, lastSeenAt: nowIso,
visitCount: nextVisitCount, visitCount: nextVisitCount,
@@ -141,7 +160,8 @@ export async function recordVisitor(req, res, overridePath = null, overrideRefer
state.visitorStats.firstVisitAt = state.visitorStats.firstVisitAt ?? nowIso state.visitorStats.firstVisitAt = state.visitorStats.firstVisitAt ?? nowIso
state.visitorStats.lastVisitAt = nowIso state.visitorStats.lastVisitAt = nowIso
state.visitorStats.recentVisits.unshift({ state.visitorStats.recentVisits.unshift({
at: nowIso, visitorId, ip, path: pathKey, referrer, device, at: nowIso, visitorId, ipHash,
path: pathKey, referrer, device,
country: geo.country, state: geo.state, county: geo.county, city: geo.city, country: geo.country, state: geo.state, county: geo.county, city: geo.city,
returningVisitor: isReturning, visitCount: nextVisitCount, returningVisitor: isReturning, visitCount: nextVisitCount,
...(studyIdentity ? { studyUser: studyIdentity } : {}), ...(studyIdentity ? { studyUser: studyIdentity } : {}),
@@ -149,6 +169,23 @@ export async function recordVisitor(req, res, overridePath = null, overrideRefer
state.visitorStats.recentVisits = state.visitorStats.recentVisits.slice(0, MAX_RECENT_VISITS) state.visitorStats.recentVisits = state.visitorStats.recentVisits.slice(0, MAX_RECENT_VISITS)
queueVisitorStatsWrite() queueVisitorStatsWrite()
// Resolve geo in background if not already cached
if (!cachedGeo) {
resolveGeo(ip, ipHash).then(resolvedGeo => {
if (state.visitorStats.visitors[visitorId]) {
state.visitorStats.visitors[visitorId].location = resolvedGeo
}
const entry = state.visitorStats.recentVisits.find(v => v.visitorId === visitorId && v.at === nowIso)
if (entry) {
entry.country = resolvedGeo.country
entry.state = resolvedGeo.state
entry.county = resolvedGeo.county
entry.city = resolvedGeo.city
}
queueVisitorStatsWrite()
}).catch(() => {})
}
} }
export function register(app) { export function register(app) {
@@ -160,6 +197,10 @@ export function register(app) {
app.post('/api/analytics/play', (req, res) => { app.post('/api/analytics/play', (req, res) => {
if (isValidAdminSession(req)) { res.json({ ok: false, reason: 'admin' }); return } 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 title = typeof req.body?.title === 'string' ? req.body.title.trim().slice(0, 200) : '' const title = typeof req.body?.title === 'string' ? req.body.title.trim().slice(0, 200) : ''
if (!title) { res.status(400).json({ ok: false, reason: 'missing-title' }); return } if (!title) { res.status(400).json({ ok: false, reason: 'missing-title' }); return }
recordEpisodePlay(title) recordEpisodePlay(title)
@@ -179,56 +220,63 @@ export function register(app) {
res.json({ ok: true }) res.json({ ok: true })
}) })
app.post('/api/analytics/pageview', async (req, res) => { app.post('/api/analytics/pageview', (req, res) => {
if (isValidAdminSession(req)) { if (isValidAdminSession(req)) { res.json({ ok: false, reason: 'admin' }); return }
res.json({ ok: false, reason: 'admin' }); return if (!hasVisitorConsent(req)) { res.json({ ok: false, reason: 'no-consent' }); return }
}
if (!hasVisitorConsent(req)) {
res.json({ ok: false, reason: 'no-consent' }); return
}
const ua = req.get('user-agent') ?? '' const ua = req.get('user-agent') ?? ''
const { isBot } = detectBot(ua) const { isBot } = detectBot(ua)
if (isBot) { if (isBot) { res.json({ ok: false, reason: 'bot' }); return }
res.json({ ok: false, reason: 'bot' }); return const ip = getClientIp(req)
} if (!pageviewAllowed(ip)) { res.json({ ok: false, reason: 'rate-limited' }); return }
const rawPath = typeof req.body?.path === 'string' ? req.body.path : '/' const rawPath = typeof req.body?.path === 'string' ? req.body.path : '/'
const rawReferrer = typeof req.body?.referrer === 'string' ? req.body.referrer : '' const rawReferrer = typeof req.body?.referrer === 'string' ? req.body.referrer : ''
recordHit(rawPath, false) recordHit(rawPath, false)
queueHitStatsWrite() queueHitStatsWrite()
await recordVisitor(req, res, rawPath, rawReferrer) recordVisitor(req, res, rawPath, rawReferrer)
res.json({ ok: true }) res.json({ ok: true })
}) })
app.get('/api/admin-stats', requireAdminAuth, (req, res) => { app.get('/api/admin-stats', requireAdminAuth, (req, res) => {
// Time-range filter: 7d | 30d | 90d (default: all-time for aggregate, 30d for charts)
const rangeParam = req.query.range const rangeParam = req.query.range
const rangeDays = rangeParam === '7d' ? 7 : rangeParam === '90d' ? 90 : 30 const isAllTime = rangeParam === 'all'
const rangeLabel = rangeParam === '7d' ? '7d' : rangeParam === '90d' ? '90d' : '30d' const rangeDays = rangeParam === '7d' ? 7 : rangeParam === '90d' ? 90 : isAllTime ? null : 30
const rangeLabel = rangeParam === '7d' ? 'Last 7 days' : rangeParam === '90d' ? 'Last 90 days' : isAllTime ? 'All time' : 'Last 30 days'
// Compute a cutoff date string (YYYY-MM-DD) for filtering daily buckets // Cutoff date (null = all-time)
const cutoffDate = (() => { const cutoffDate = isAllTime ? null : (() => {
const d = new Date() const d = new Date()
d.setDate(d.getDate() - (rangeDays - 1)) d.setDate(d.getDate() - ((rangeDays ?? 30) - 1))
return d.toISOString().slice(0, 10) return d.toISOString().slice(0, 10)
})() })()
// Filter byDayReal/byDayBot keys to only those within the range // All known day keys, sorted
const allDayKeys = new Set([ const allDayKeys = new Set([
...Object.keys(state.hitStats.byDayReal ?? {}), ...Object.keys(state.hitStats.byDayReal ?? {}),
...Object.keys(state.hitStats.byDayBot ?? {}), ...Object.keys(state.hitStats.byDayBot ?? {}),
]) ])
const filteredDayKeys = [...allDayKeys].filter(day => day >= cutoffDate) const filteredDayKeys = isAllTime
? [...allDayKeys].sort()
: [...allDayKeys].filter(day => day >= cutoffDate).sort()
// Aggregate hits for the range // Aggregate hits for the range
const rangeRealHits = filteredDayKeys.reduce((sum, day) => sum + (state.hitStats.byDayReal?.[day] ?? 0), 0) const rangeRealHits = filteredDayKeys.reduce((sum, day) => sum + (state.hitStats.byDayReal?.[day] ?? 0), 0)
const rangeBotHits = filteredDayKeys.reduce((sum, day) => sum + (state.hitStats.byDayBot?.[day] ?? 0), 0) const rangeBotHits = filteredDayKeys.reduce((sum, day) => sum + (state.hitStats.byDayBot?.[day] ?? 0), 0)
const rangeTotalHits = rangeRealHits + rangeBotHits const rangeTotalHits = rangeRealHits + rangeBotHits
// Filter per-path stats by range — approximate using recent visitor rows scoped to range // Trend chart data — one point per day for the selected range
const rangeVisitorRows = state.visitorStats.recentVisits.filter(row => { const trendData = filteredDayKeys.map(day => ({
if (!row.at) return false // exclude rows with no timestamp from range views day,
return row.at >= cutoffDate real: state.hitStats.byDayReal?.[day] ?? 0,
}) bot: state.hitStats.byDayBot?.[day] ?? 0,
}))
// Visitor rows for range
const rangeVisitorRows = isAllTime
? state.visitorStats.recentVisits
: state.visitorStats.recentVisits.filter(row => {
if (!row.at) return false
return row.at >= cutoffDate
})
const rangePathCountsReal = {} const rangePathCountsReal = {}
const rangePathCountsBot = {} const rangePathCountsBot = {}
@@ -257,9 +305,6 @@ export function register(app) {
const botReasons = Object.entries(state.hitStats.botReasons ?? {}) const botReasons = Object.entries(state.hitStats.botReasons ?? {})
.sort((a, b) => b[1] - a[1]).slice(0, 10).map(([reason, count]) => ({ reason, count })) .sort((a, b) => b[1] - a[1]).slice(0, 10).map(([reason, count]) => ({ reason, count }))
// All range rows used for geographic/device/referrer aggregates — no cap so no visits are invisible
const allRangeRows = rangeVisitorRows
// Capped to 100 for the visitor log display (with page history hydrated) // Capped to 100 for the visitor log display (with page history hydrated)
const recentVisitorRows = rangeVisitorRows.slice(0, 100).map(row => { const recentVisitorRows = rangeVisitorRows.slice(0, 100).map(row => {
const fullVisitor = state.visitorStats.visitors[row.visitorId] const fullVisitor = state.visitorStats.visitors[row.visitorId]
@@ -295,7 +340,6 @@ export function register(app) {
const enrolledUsers = state.studyUsers.filter(user => (user.enrolledStudySlugs?.length ?? 0) > 0).length const enrolledUsers = state.studyUsers.filter(user => (user.enrolledStudySlugs?.length ?? 0) > 0).length
const totalEnrollments = Object.values(enrollmentCountsBySlug).reduce((sum, count) => sum + count, 0) const totalEnrollments = Object.values(enrollmentCountsBySlug).reduce((sum, count) => sum + count, 0)
// Enrollment funnel: signups → first study visit → first section completed
const funnelSignups = state.studyUsers.length const funnelSignups = state.studyUsers.length
const funnelFirstVisit = state.studyUsers.filter(u => u.firstVisitAt).length const funnelFirstVisit = state.studyUsers.filter(u => u.firstVisitAt).length
const funnelFirstCompletion = state.studyUsers.filter(u => u.firstCompletionAt).length const funnelFirstCompletion = state.studyUsers.filter(u => u.firstCompletionAt).length
@@ -307,6 +351,8 @@ export function register(app) {
rangeTotalHits, rangeTotalHits,
rangeLabel, rangeLabel,
rangeDays, rangeDays,
isAllTime,
trendData,
// All-time totals for reference // All-time totals for reference
allTimeRealHits: state.hitStats.realHits ?? 0, allTimeRealHits: state.hitStats.realHits ?? 0,
allTimeBotHits: state.hitStats.botHits ?? 0, allTimeBotHits: state.hitStats.botHits ?? 0,
@@ -323,13 +369,13 @@ export function register(app) {
returningVisits: state.visitorStats.returningVisits, returningVisits: state.visitorStats.returningVisits,
firstVisitAt: state.visitorStats.firstVisitAt, firstVisitAt: state.visitorStats.firstVisitAt,
lastVisitAt: state.visitorStats.lastVisitAt, lastVisitAt: state.visitorStats.lastVisitAt,
topCountries: buildTopLocations(allRangeRows, 'country'), topCountries: buildTopLocations(rangeVisitorRows, 'country'),
topStates: buildTopLocations(allRangeRows, 'state'), topStates: buildTopLocations(rangeVisitorRows, 'state'),
topCounties: buildTopLocations(allRangeRows, 'county'), topCounties: buildTopLocations(rangeVisitorRows, 'county'),
topCities: buildTopLocations(allRangeRows, 'city'), topCities: buildTopLocations(rangeVisitorRows, 'city'),
deviceBreakdown: (() => { deviceBreakdown: (() => {
const counts = { mobile: 0, desktop: 0, tablet: 0, unknown: 0 } const counts = { mobile: 0, desktop: 0, tablet: 0, unknown: 0 }
for (const row of allRangeRows) { for (const row of rangeVisitorRows) {
const d = row.device ?? 'unknown' const d = row.device ?? 'unknown'
counts[d] = (counts[d] ?? 0) + 1 counts[d] = (counts[d] ?? 0) + 1
} }
@@ -337,7 +383,7 @@ export function register(app) {
})(), })(),
topReferrers: (() => { topReferrers: (() => {
const counts = {} const counts = {}
for (const row of allRangeRows) { for (const row of rangeVisitorRows) {
if (!row.referrer) continue if (!row.referrer) continue
counts[row.referrer] = (counts[row.referrer] ?? 0) + 1 counts[row.referrer] = (counts[row.referrer] ?? 0) + 1
} }
+4 -4
View File
@@ -546,7 +546,7 @@ export interface AdminStats {
recentVisits: Array<{ recentVisits: Array<{
at: string at: string
visitorId: string visitorId: string
ip: string ipHash?: string
path: string path: string
referrer?: string referrer?: string
device?: string device?: string
@@ -1531,7 +1531,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const [navOpenGroups, setNavOpenGroups] = useState<Set<string>>(new Set()) const [navOpenGroups, setNavOpenGroups] = useState<Set<string>>(new Set())
const [stats, setStats] = useState<AdminStats | null>(null) const [stats, setStats] = useState<AdminStats | null>(null)
const [statsStatus, setStatsStatus] = useState<'loading' | 'ready' | 'error'>('loading') const [statsStatus, setStatsStatus] = useState<'loading' | 'ready' | 'error'>('loading')
const [statsRange, setStatsRange] = useState<'7d' | '30d' | '90d'>('30d') const [statsRange, setStatsRange] = useState<'7d' | '30d' | '90d' | 'all'>('30d')
const [maintenanceMsg, setMaintenanceMsg] = useState('') const [maintenanceMsg, setMaintenanceMsg] = useState('')
const [opsMsg, setOpsMsg] = useState('') const [opsMsg, setOpsMsg] = useState('')
const [backupFiles, setBackupFiles] = useState<BackupPreview[]>([]) const [backupFiles, setBackupFiles] = useState<BackupPreview[]>([])
@@ -1883,7 +1883,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date())
}) })
}, [selectedBackup]) }, [selectedBackup])
async function reloadStats(range?: '7d' | '30d' | '90d') { async function reloadStats(range?: '7d' | '30d' | '90d' | 'all') {
const r = await fetch(`/api/admin-stats?range=${range ?? statsRange}`) const r = await fetch(`/api/admin-stats?range=${range ?? statsRange}`)
if (!r.ok) throw new Error('Could not refresh stats') if (!r.ok) throw new Error('Could not refresh stats')
const data = await r.json() const data = await r.json()
@@ -5592,7 +5592,7 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
stats={stats} stats={stats}
statsStatus={statsStatus} statsStatus={statsStatus}
statsRange={statsRange} statsRange={statsRange}
onRangeChange={async (range) => { onRangeChange={async (range: '7d' | '30d' | '90d' | 'all') => {
setStatsRange(range) setStatsRange(range)
setStatsStatus('loading') setStatsStatus('loading')
try { await reloadStats(range) } catch { setStatsStatus('error') } try { await reloadStats(range) } catch { setStatsStatus('error') }
+30 -14
View File
@@ -2,11 +2,28 @@ import { useEffect, useRef } from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
const CONSENT_KEY = 'vbn_analytics_consent_choice' const CONSENT_KEY = 'vbn_analytics_consent_choice'
const UTM_PENDING_KEY = 'vbn_pending_utm'
function hasConsent(): boolean { function hasConsent(): boolean {
return localStorage.getItem(CONSENT_KEY) === 'accepted' return localStorage.getItem(CONSENT_KEY) === 'accepted'
} }
// Capture UTM params immediately on module load, before consent is given.
// They're held in sessionStorage so they survive a consent-banner accept.
;(function captureUtmEarly() {
try {
const params = new URLSearchParams(window.location.search)
const utm: Record<string, string> = {}
for (const key of ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term']) {
const val = params.get(key)
if (val) utm[key] = val
}
if (Object.keys(utm).length > 0) {
sessionStorage.setItem(UTM_PENDING_KEY, JSON.stringify({ path: window.location.pathname, ...utm }))
}
} catch { /* sessionStorage unavailable */ }
})()
export function sendEvent(type: string, data: Record<string, unknown>): void { export function sendEvent(type: string, data: Record<string, unknown>): void {
if (!hasConsent()) return if (!hasConsent()) return
fetch('/api/analytics/event', { fetch('/api/analytics/event', {
@@ -69,22 +86,21 @@ export function useTimeOnPage() {
}, [location.pathname]) }, [location.pathname])
} }
// --- UTM capture (runs once per page load) --- // --- UTM capture ---
// UTM params are captured into sessionStorage before consent (see top of file).
// This hook drains sessionStorage and sends the event once consent is present.
// Running without a dep array so it retries after the consent banner triggers a re-render.
export function useUTMCapture() { export function useUTMCapture() {
const captured = useRef(false)
useEffect(() => { useEffect(() => {
if (captured.current || !hasConsent()) return if (!hasConsent()) return
const params = new URLSearchParams(window.location.search) try {
const utm: Record<string, string> = {} const raw = sessionStorage.getItem(UTM_PENDING_KEY)
for (const key of ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term']) { if (!raw) return
const val = params.get(key) const utm = JSON.parse(raw) as Record<string, string>
if (val) utm[key] = val sessionStorage.removeItem(UTM_PENDING_KEY)
} sendEvent('utm', utm)
if (Object.keys(utm).length === 0) return } catch { /* ignore */ }
captured.current = true })
sendEvent('utm', { path: window.location.pathname, ...utm })
}, [])
} }
// --- All link clicks (internal + external) --- // --- All link clicks (internal + external) ---
+168 -167
View File
@@ -27,11 +27,13 @@ ChartJS.register(
ArcElement, ArcElement,
) )
type StatsRange = '7d' | '30d' | '90d' | 'all'
interface Props { interface Props {
stats: AdminStats | null stats: AdminStats | null
statsStatus: 'loading' | 'error' | 'ready' statsStatus: 'loading' | 'error' | 'ready'
statsRange?: '7d' | '30d' | '90d' statsRange?: StatsRange
onRangeChange?: (range: '7d' | '30d' | '90d') => void onRangeChange?: (range: StatsRange) => void
opsStatus: { buildCommit: string | null; buildNumber: string | null; deployedAt: string | null; cachePurge: { ok: boolean; at: string | null; error: string | null }; deployHook: { ok: boolean; at: string | null; error: string | null } } | null opsStatus: { buildCommit: string | null; buildNumber: string | null; deployedAt: string | null; cachePurge: { ok: boolean; at: string | null; error: string | null }; deployHook: { ok: boolean; at: string | null; error: string | null } } | null
formatDate: (date: string | null) => string formatDate: (date: string | null) => string
maskIp: (ip: string) => string maskIp: (ip: string) => string
@@ -53,6 +55,37 @@ interface Props {
onRefreshStatus: () => void onRefreshStatus: () => void
} }
const RANGE_LABELS: Record<StatsRange, string> = {
'7d': 'Last 7 days',
'30d': 'Last 30 days',
'90d': 'Last 90 days',
'all': 'All time',
}
function fmtSec(s: number) {
if (s >= 3600) return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`
if (s >= 60) return `${Math.floor(s / 60)}m ${s % 60}s`
return `${s}s`
}
// Aggregate daily data into weekly buckets when there are many points (all-time)
function aggregateTrend(data: Array<{ day: string; real: number; bot: number }>) {
if (data.length <= 60) return data
// Group by ISO week (YYYY-Www) — bucket by ~7 days
const buckets: Record<string, { day: string; real: number; bot: number }> = {}
for (const d of data) {
const date = new Date(d.day)
// Bucket key: year + week number
const startOfYear = new Date(date.getFullYear(), 0, 1)
const week = Math.ceil(((date.getTime() - startOfYear.getTime()) / 86400000 + startOfYear.getDay() + 1) / 7)
const key = `${date.getFullYear()}-W${String(week).padStart(2, '0')}`
if (!buckets[key]) buckets[key] = { day: d.day, real: 0, bot: 0 }
buckets[key].real += d.real
buckets[key].bot += d.bot
}
return Object.values(buckets)
}
export function AnalyticsPanel({ export function AnalyticsPanel({
stats, stats,
statsStatus, statsStatus,
@@ -60,7 +93,6 @@ export function AnalyticsPanel({
onRangeChange, onRangeChange,
opsStatus, opsStatus,
formatDate, formatDate,
maskIp,
maintenanceMsg, maintenanceMsg,
selectedBackup, selectedBackup,
backupFiles, backupFiles,
@@ -90,7 +122,6 @@ export function AnalyticsPanel({
// Normalize fields that may be missing from older data // Normalize fields that may be missing from older data
const realHits = stats.realHits ?? 0 const realHits = stats.realHits ?? 0
const botHits = stats.botHits ?? 0 const botHits = stats.botHits ?? 0
const totalHits = stats.totalHits ?? 0
const last7DaysReal = stats.last7DaysReal ?? stats.last7Days.map((d: { day: string; hits: number }) => ({ day: d.day, hits: 0 })) const last7DaysReal = stats.last7DaysReal ?? stats.last7Days.map((d: { day: string; hits: number }) => ({ day: d.day, hits: 0 }))
const last7DaysBot = stats.last7DaysBot ?? stats.last7Days.map((d: { day: string; hits: number }) => ({ day: d.day, hits: 0 })) const last7DaysBot = stats.last7DaysBot ?? stats.last7Days.map((d: { day: string; hits: number }) => ({ day: d.day, hits: 0 }))
const last30DaysRealTotal = stats.last30DaysRealTotal ?? 0 const last30DaysRealTotal = stats.last30DaysRealTotal ?? 0
@@ -99,51 +130,46 @@ export function AnalyticsPanel({
const topPathsBot = stats.topPathsBot ?? [] const topPathsBot = stats.topPathsBot ?? []
const botReasons = stats.botReasons ?? [] const botReasons = stats.botReasons ?? []
// Chart data - Daily trend const rangeTotalHits = (stats as AdminStats & { rangeTotalHits?: number }).rangeTotalHits ?? stats.totalHits
const dailyChartData = { const rangeLabel = (stats as AdminStats & { rangeLabel?: string }).rangeLabel ?? RANGE_LABELS[statsRange]
labels: stats.last7Days.map((d: { day: string; hits: number }) => d.day), const rawTrendData = (stats as AdminStats & { trendData?: Array<{ day: string; real: number; bot: number }> }).trendData
// Fall back to last7Days format if trendData not present (old API)
const trendPoints = rawTrendData
? aggregateTrend(rawTrendData)
: last7DaysReal.map((d: { day: string; hits: number }, i: number) => ({
day: d.day,
real: d.hits,
bot: last7DaysBot[i]?.hits ?? 0,
}))
const trendTitle = `Traffic Trend — ${rangeLabel}`
const trendLabels = trendPoints.map(d => d.day.length === 10 ? d.day.slice(5) : d.day)
// Trend chart data
const trendChartData = {
labels: trendLabels,
datasets: [ datasets: [
{ {
label: 'Real Visitors', label: 'Real Visitors',
data: last7DaysReal.map((d: { day: string; hits: number }) => d.hits), data: trendPoints.map(d => d.real),
borderColor: '#c9a84c', borderColor: '#c9a84c',
backgroundColor: 'rgba(201, 168, 76, 0.1)', backgroundColor: 'rgba(201, 168, 76, 0.1)',
tension: 0.4, tension: 0.3,
fill: true, fill: true,
pointRadius: trendPoints.length > 90 ? 0 : 2,
}, },
{ {
label: 'Bot Traffic', label: 'Bot Traffic',
data: last7DaysBot.map((d: { day: string; hits: number }) => d.hits), data: trendPoints.map(d => d.bot),
borderColor: '#666', borderColor: '#666',
backgroundColor: 'rgba(102, 102, 102, 0.1)', backgroundColor: 'rgba(102, 102, 102, 0.1)',
tension: 0.4, tension: 0.3,
fill: true, fill: true,
pointRadius: trendPoints.length > 90 ? 0 : 2,
}, },
], ],
} }
// Chart data - Bot breakdown (pie)
const botPieData = {
labels: botReasons.map((b: { reason: string; count: number }) => b.reason.replace(/-/g, ' ')),
datasets: [{
data: botReasons.map((b: { reason: string; count: number }) => b.count),
backgroundColor: [
'#c9a84c',
'#a0853d',
'#6d5a2e',
'#8b7635',
'#9e8843',
'#bbb477',
'#7a6930',
'#5a4820',
'#c0a060',
'#746c36',
],
borderColor: '#1a1a15',
borderWidth: 2,
}],
}
// Traffic ratio (doughnut) // Traffic ratio (doughnut)
const trafficRatioData = { const trafficRatioData = {
labels: ['Real Visitors', 'Bot Traffic'], labels: ['Real Visitors', 'Bot Traffic'],
@@ -155,44 +181,30 @@ export function AnalyticsPanel({
}], }],
} }
// Top paths comparison // Bot breakdown (pie)
const botPieData = {
labels: botReasons.map((b: { reason: string; count: number }) => b.reason.replace(/-/g, ' ')),
datasets: [{
data: botReasons.map((b: { reason: string; count: number }) => b.count),
backgroundColor: ['#c9a84c','#a0853d','#6d5a2e','#8b7635','#9e8843','#bbb477','#7a6930','#5a4820','#c0a060','#746c36'],
borderColor: '#1a1a15',
borderWidth: 2,
}],
}
// Top paths
const topPathsLabels = Array.from({ length: Math.max(topPathsReal.length, topPathsBot.length) }, (_, i: number) => { const topPathsLabels = Array.from({ length: Math.max(topPathsReal.length, topPathsBot.length) }, (_, i: number) => {
const realPath = topPathsReal[i]?.path || '' return topPathsReal[i]?.path || topPathsBot[i]?.path || `Path ${i + 1}`
const botPath = topPathsBot[i]?.path || ''
return realPath || botPath || `Path ${i + 1}`
}).slice(0, 8) }).slice(0, 8)
const topPathsChartData = { const topPathsChartData = {
labels: topPathsLabels, labels: topPathsLabels,
datasets: [ datasets: [
{ { label: 'Real Hits', data: topPathsLabels.map((_l, i) => topPathsReal[i]?.hits || 0), backgroundColor: '#c9a84c' },
label: 'Real Hits', { label: 'Bot Hits', data: topPathsLabels.map((_l, i) => topPathsBot[i]?.hits || 0), backgroundColor: '#999' },
data: topPathsLabels.map((_label, i) => topPathsReal[i]?.hits || 0),
backgroundColor: '#c9a84c',
},
{
label: 'Bot Hits',
data: topPathsLabels.map((_label, i) => topPathsBot[i]?.hits || 0),
backgroundColor: '#999',
},
], ],
} }
// 30-day trend chart
const last30DaysReal = stats.visitors.last30DaysReal ?? []
const thirtyDayChartData = {
labels: last30DaysReal.map((d: { day: string; hits: number }) => d.day.slice(5)),
datasets: [{
label: 'Real Visitors',
data: last30DaysReal.map((d: { day: string; hits: number }) => d.hits),
borderColor: '#c9a84c',
backgroundColor: 'rgba(201, 168, 76, 0.08)',
tension: 0.3,
fill: true,
pointRadius: 2,
}],
}
// Device breakdown (doughnut) // Device breakdown (doughnut)
const deviceBreakdown = stats.visitors.deviceBreakdown ?? { mobile: 0, desktop: 0, tablet: 0, unknown: 0 } const deviceBreakdown = stats.visitors.deviceBreakdown ?? { mobile: 0, desktop: 0, tablet: 0, unknown: 0 }
const deviceChartData = { const deviceChartData = {
@@ -209,41 +221,22 @@ export function AnalyticsPanel({
responsive: true, responsive: true,
maintainAspectRatio: true, maintainAspectRatio: true,
plugins: { plugins: {
legend: { legend: { labels: { color: '#f0ead8', font: { size: 12 } } },
labels: { tooltip: { backgroundColor: '#2a2518', titleColor: '#f0ead8', bodyColor: '#f0ead8', borderColor: '#c9a84c', borderWidth: 1 },
color: '#f0ead8',
font: { size: 12 },
},
},
tooltip: {
backgroundColor: '#2a2518',
titleColor: '#f0ead8',
bodyColor: '#f0ead8',
borderColor: '#c9a84c',
borderWidth: 1,
},
}, },
scales: { scales: {
y: { y: { beginAtZero: true, ticks: { color: '#7a7060' }, grid: { color: '#2a2518' } },
beginAtZero: true, x: { ticks: { color: '#7a7060', maxTicksLimit: 12 }, grid: { color: '#2a2518' } },
ticks: { color: '#7a7060' },
grid: { color: '#2a2518' },
},
x: {
ticks: { color: '#7a7060' },
grid: { color: '#2a2518' },
},
}, },
} }
const rangeTotalHits = (stats as AdminStats & { rangeTotalHits?: number }).rangeTotalHits ?? totalHits const rangeRealVisitors = stats.visitors.recentVisits.length
const rangeLabel = (stats as AdminStats & { rangeLabel?: string }).rangeLabel ?? statsRange
return ( return (
<section className="admin-panel-section" aria-label="Analytics"> <section className="admin-panel-section" aria-label="Analytics">
<div className="admin-panel-head"> <div className="admin-panel-head">
<h2>Analytics Dashboard</h2> <h2>Analytics Dashboard</h2>
<p>Real-time insights into site traffic, bot detection, visitor behavior, and data management.</p> <p>Site traffic, bot detection, visitor behavior, and data management.</p>
</div> </div>
{/* Top-level panel tabs */} {/* Top-level panel tabs */}
@@ -254,10 +247,10 @@ export function AnalyticsPanel({
</div> </div>
{panelTab === 'traffic' && (<> {panelTab === 'traffic' && (<>
{/* Time-range tabs */} {/* Time-range selector */}
{onRangeChange && ( {onRangeChange && (
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
{(['7d', '30d', '90d'] as const).map(r => ( {(['7d', '30d', '90d', 'all'] as StatsRange[]).map(r => (
<button <button
key={r} key={r}
type="button" type="button"
@@ -274,36 +267,33 @@ export function AnalyticsPanel({
transition: 'all 0.15s', transition: 'all 0.15s',
}} }}
> >
{r === '7d' ? 'Last 7 days' : r === '30d' ? 'Last 30 days' : 'Last 90 days'} {RANGE_LABELS[r]}
</button> </button>
))} ))}
<span style={{ color: '#5a5440', fontSize: '0.8rem', alignSelf: 'center', marginLeft: '0.25rem' }}>
Showing: {rangeLabel}
</span>
</div> </div>
)} )}
{/* KPI Grid */} {/* KPI Grid */}
<div className="admin-stats-grid"> <div className="admin-stats-grid">
<article> <article>
<h3>Total Hits</h3> <h3>Real Hits</h3>
<p>{rangeTotalHits.toLocaleString()}</p>
<small>{rangeLabel} all requests</small>
</article>
<article>
<h3>Real Traffic</h3>
<p>{realHits.toLocaleString()}</p> <p>{realHits.toLocaleString()}</p>
<small>{rangeTotalHits > 0 ? ((realHits / rangeTotalHits) * 100).toFixed(1) : '0.0'}% of total</small> <small>{rangeLabel}</small>
</article> </article>
<article> <article>
<h3>Bot Traffic</h3> <h3>Bot Traffic</h3>
<p>{botHits.toLocaleString()}</p> <p>{botHits.toLocaleString()}</p>
<small>{rangeTotalHits > 0 ? ((botHits / rangeTotalHits) * 100).toFixed(1) : '0.0'}% of total</small> <small>{rangeTotalHits > 0 ? `${((botHits / rangeTotalHits) * 100).toFixed(1)}% of total` : '0% of total'}</small>
</article>
<article>
<h3>All-Time Real Hits</h3>
<p>{((stats as AdminStats & { allTimeRealHits?: number }).allTimeRealHits ?? stats.totalHits).toLocaleString()}</p>
<small>Since {formatDate(stats.firstHitAt)}</small>
</article> </article>
<article> <article>
<h3>Last 30 Days</h3> <h3>Last 30 Days</h3>
<p>{last30DaysRealTotal.toLocaleString()}</p> <p>{last30DaysRealTotal.toLocaleString()}</p>
<small>Real hits • {last30DaysBotTotal.toLocaleString()} bots</small> <small>Real hits · {last30DaysBotTotal.toLocaleString()} bots</small>
</article> </article>
<article> <article>
<h3>Unique Visitors</h3> <h3>Unique Visitors</h3>
@@ -329,19 +319,23 @@ export function AnalyticsPanel({
{chartTab === 'overview' && ( {chartTab === 'overview' && (
<> <>
<div className="admin-analytics-grid"> <div className="admin-analytics-grid">
<div className="admin-analytics-card"> <div className="admin-analytics-card admin-analytics-card--wide">
<h3 className="admin-analytics-card-title">7-Day Trend (Real vs Bot)</h3> <h3 className="admin-analytics-card-title">{trendTitle}</h3>
<Line data={dailyChartData} options={chartOptions} /> <Line data={trendChartData} options={chartOptions} />
</div>
<div className="admin-analytics-card">
<h3 className="admin-analytics-card-title">Traffic Composition</h3>
<Doughnut data={trafficRatioData} options={{...chartOptions, plugins: {...chartOptions.plugins}}} />
</div> </div>
</div> </div>
<div className="admin-analytics-grid" style={{ marginTop: '1rem' }}> <div className="admin-analytics-grid" style={{ marginTop: '1rem' }}>
<div className="admin-analytics-card admin-analytics-card--wide"> <div className="admin-analytics-card">
<h3 className="admin-analytics-card-title">30-Day Real Visitor Trend</h3> <h3 className="admin-analytics-card-title">Traffic Composition ({rangeLabel})</h3>
<Line data={thirtyDayChartData} options={chartOptions} /> <Doughnut data={trafficRatioData} options={chartOptions} />
</div>
<div className="admin-analytics-card">
<h3 className="admin-analytics-card-title">Top Pages ({rangeLabel})</h3>
<ul className="admin-bot-reason-list">
{topPathsReal.slice(0, 8).map((item: { path: string; hits: number }) => (
<li key={item.path}><span>{item.path}</span><strong>{item.hits.toLocaleString()}</strong></li>
))}
</ul>
</div> </div>
</div> </div>
</> </>
@@ -353,7 +347,7 @@ export function AnalyticsPanel({
<div className="admin-analytics-grid"> <div className="admin-analytics-grid">
<div className="admin-analytics-card"> <div className="admin-analytics-card">
<h3 className="admin-analytics-card-title">Device Breakdown</h3> <h3 className="admin-analytics-card-title">Device Breakdown</h3>
<Doughnut data={deviceChartData} options={{...chartOptions, plugins: {...chartOptions.plugins}}} /> <Doughnut data={deviceChartData} options={chartOptions} />
</div> </div>
<div className="admin-analytics-card"> <div className="admin-analytics-card">
<h3 className="admin-analytics-card-title">Device Counts</h3> <h3 className="admin-analytics-card-title">Device Counts</h3>
@@ -371,7 +365,7 @@ export function AnalyticsPanel({
{botReasons.length === 0 ? ( {botReasons.length === 0 ? (
<p className="admin-stats-note">No bot traffic detected.</p> <p className="admin-stats-note">No bot traffic detected.</p>
) : ( ) : (
<Pie data={botPieData} options={{...chartOptions, plugins: {...chartOptions.plugins}}} /> <Pie data={botPieData} options={chartOptions} />
)} )}
</div> </div>
<div className="admin-analytics-card"> <div className="admin-analytics-card">
@@ -390,20 +384,40 @@ export function AnalyticsPanel({
)} )}
</div> </div>
</div> </div>
<div className="admin-analytics-grid" style={{ marginTop: '1rem' }}>
<div className="admin-analytics-card admin-analytics-card--wide">
<h3 className="admin-analytics-card-title">Top Pages — Real vs Bot ({rangeLabel})</h3>
<Bar data={topPathsChartData} options={{...chartOptions, indexAxis: 'y' as const}} />
</div>
</div>
</> </>
)} )}
{/* Geographic Charts */} {/* Geographic Charts */}
{chartTab === 'geographic' && ( {chartTab === 'geographic' && (
<div className="admin-analytics-geo"> <div className="admin-analytics-geo">
<div className="admin-analytics-card admin-analytics-card--wide">
<h3 className="admin-analytics-card-title">Top Pages</h3>
<Bar data={topPathsChartData} options={{...chartOptions, indexAxis: 'y' as const}} />
</div>
<GeoMaps <GeoMaps
topCountries={stats.visitors.topCountries} topCountries={stats.visitors.topCountries}
topStates={stats.visitors.topStates} topStates={stats.visitors.topStates}
/> />
<div className="admin-stats-lists" style={{ marginTop: '1rem' }}>
<div>
<h3>Top Cities</h3>
<ul>
{(stats.visitors.topCities ?? []).slice(0, 10).map((item: { name: string; hits: number }) => (
<li key={item.name}><span>{item.name}</span><strong>{item.hits.toLocaleString()}</strong></li>
))}
</ul>
</div>
<div>
<h3>Top Countries</h3>
<ul>
{stats.visitors.topCountries.slice(0, 10).map((item: { name: string; hits: number }) => (
<li key={item.name}><span>{item.name}</span><strong>{item.hits.toLocaleString()}</strong></li>
))}
</ul>
</div>
</div>
</div> </div>
)} )}
@@ -411,7 +425,7 @@ export function AnalyticsPanel({
{chartTab === 'referrers' && ( {chartTab === 'referrers' && (
<div className="admin-analytics-grid"> <div className="admin-analytics-grid">
<div className="admin-analytics-card admin-analytics-card--wide"> <div className="admin-analytics-card admin-analytics-card--wide">
<h3 className="admin-analytics-card-title">Top Traffic Sources</h3> <h3 className="admin-analytics-card-title">Top Traffic Sources ({rangeLabel})</h3>
{(stats.visitors.topReferrers ?? []).length === 0 ? ( {(stats.visitors.topReferrers ?? []).length === 0 ? (
<p className="admin-stats-note">No referrer data yet. Referrers are captured on SPA navigations after consent.</p> <p className="admin-stats-note">No referrer data yet. Referrers are captured on SPA navigations after consent.</p>
) : ( ) : (
@@ -428,16 +442,16 @@ export function AnalyticsPanel({
</div> </div>
)} )}
{/* Recent Visitors Table */} {/* Recent Visitors */}
<div className="admin-stats-head admin-stats-head--visitors"> <div className="admin-stats-head admin-stats-head--visitors">
<h2>Visitor Details</h2> <h2>Visitor Log</h2>
<p>Real-time tracking of visitor activity, geography, and returning behavior.</p> <p>Activity for {rangeLabel.toLowerCase()} · {rangeRealVisitors} records shown.</p>
</div> </div>
<p className="admin-privacy-note">Privacy: visitor analytics only run after cookie consent. IPs below are masked.</p> <p className="admin-privacy-note">Analytics only run after cookie consent. Visitor IDs are pseudonymous hash prefixes.</p>
<div className="admin-stats-grid admin-stats-grid--visitor"> <div className="admin-stats-grid admin-stats-grid--visitor">
<article><h3>Total Visits</h3><p>{stats.visitors.totalVisits.toLocaleString()}</p></article> <article><h3>Total Visits</h3><p>{stats.visitors.totalVisits.toLocaleString()}</p><small>All time</small></article>
<article><h3>Unique Visitors</h3><p>{stats.visitors.uniqueVisitors.toLocaleString()}</p></article> <article><h3>Unique Visitors</h3><p>{stats.visitors.uniqueVisitors.toLocaleString()}</p><small>All time</small></article>
<article><h3>Returning Visits</h3><p>{stats.visitors.returningVisits.toLocaleString()}</p></article> <article><h3>Returning Visits</h3><p>{stats.visitors.returningVisits.toLocaleString()}</p></article>
<article><h3>First Visit</h3><p>{formatDate(stats.visitors.firstVisitAt)}</p></article> <article><h3>First Visit</h3><p>{formatDate(stats.visitors.firstVisitAt)}</p></article>
</div> </div>
@@ -472,6 +486,9 @@ export function AnalyticsPanel({
const isExpanded = expandedVisitor === rowKey const isExpanded = expandedVisitor === rowKey
const history = row.pageHistory ?? [] const history = row.pageHistory ?? []
const location = [row.city !== 'Unknown' && row.city, row.state !== 'Unknown' && row.state, row.country !== 'Unknown' && row.country].filter(Boolean).join(', ') || 'Unknown location' const location = [row.city !== 'Unknown' && row.city, row.state !== 'Unknown' && row.state, row.country !== 'Unknown' && row.country].filter(Boolean).join(', ') || 'Unknown location'
const visitorLabel = row.studyUser
? (row.studyUser.displayName || row.studyUser.username)
: `Visitor ${(row.ipHash ?? row.visitorId).slice(0, 8)}`
return ( return (
<div key={rowKey} className={`admin-visitor-card${isExpanded ? ' admin-visitor-card--expanded' : ''}`}> <div key={rowKey} className={`admin-visitor-card${isExpanded ? ' admin-visitor-card--expanded' : ''}`}>
<button <button
@@ -481,7 +498,9 @@ export function AnalyticsPanel({
> >
<div className="admin-visitor-card__left"> <div className="admin-visitor-card__left">
<span className="admin-visitor-card__name"> <span className="admin-visitor-card__name">
{row.studyUser ? (row.studyUser.displayName || row.studyUser.username) : <code className="admin-visitor-ip">{maskIp(row.ip)}</code>} {row.studyUser
? visitorLabel
: <code className="admin-visitor-ip">{visitorLabel}</code>}
</span> </span>
<span className="admin-visitor-card__path">{row.path}</span> <span className="admin-visitor-card__path">{row.path}</span>
</div> </div>
@@ -502,7 +521,7 @@ export function AnalyticsPanel({
{isExpanded && ( {isExpanded && (
<div className="admin-visitor-card__detail"> <div className="admin-visitor-card__detail">
<div className="admin-visitor-card__detail-meta"> <div className="admin-visitor-card__detail-meta">
<span><strong>IP</strong> {maskIp(row.ip)}</span> <span><strong>ID</strong> {(row.ipHash ?? row.visitorId).slice(0, 12)}…</span>
{row.studyUser && <span><strong>Username</strong> {row.studyUser.username}</span>} {row.studyUser && <span><strong>Username</strong> {row.studyUser.username}</span>}
{row.referrer && <span><strong>Referrer</strong> {row.referrer}</span>} {row.referrer && <span><strong>Referrer</strong> {row.referrer}</span>}
<span><strong>Location</strong> {[row.city, row.county, row.state, row.country].filter(v => v && v !== 'Unknown').join(', ') || '—'}</span> <span><strong>Location</strong> {[row.city, row.county, row.state, row.country].filter(v => v && v !== 'Unknown').join(', ') || '—'}</span>
@@ -540,7 +559,12 @@ export function AnalyticsPanel({
<> <>
<div className="admin-stats-head admin-stats-head--visitors"> <div className="admin-stats-head admin-stats-head--visitors">
<h2>Episode Plays</h2> <h2>Episode Plays</h2>
<p>Tracked each time a visitor hits play on the audio player. One count per player mount.</p> <p>Counted each time a visitor presses play. One count per player mount.</p>
</div>
<div className="admin-stats-grid" style={{ marginBottom: '1.5rem' }}>
<article><h3>Total Plays</h3><p>{stats.episodePlays.reduce((sum: number, ep: { total: number }) => sum + ep.total, 0).toLocaleString()}</p></article>
<article><h3>Episodes Played</h3><p>{stats.episodePlays.length.toLocaleString()}</p></article>
<article><h3>Most Played</h3><p style={{ fontSize: '0.8rem' }}>{stats.episodePlays[0]?.title ?? '—'}</p><small>{stats.episodePlays[0]?.total.toLocaleString()} plays</small></article>
</div> </div>
<div className="admin-stats-chart-wrap" style={{ marginBottom: '1.5rem' }}> <div className="admin-stats-chart-wrap" style={{ marginBottom: '1.5rem' }}>
<Bar <Bar
@@ -571,11 +595,6 @@ export function AnalyticsPanel({
style={{ height: `${Math.max(180, stats.episodePlays.slice(0, 12).length * 36)}px` }} style={{ height: `${Math.max(180, stats.episodePlays.slice(0, 12).length * 36)}px` }}
/> />
</div> </div>
<div className="admin-stats-grid" style={{ marginBottom: '1.5rem' }}>
<article><h3>Total Episode Plays</h3><p>{stats.episodePlays.reduce((sum: number, ep: { total: number }) => sum + ep.total, 0).toLocaleString()}</p></article>
<article><h3>Episodes Played</h3><p>{stats.episodePlays.length.toLocaleString()}</p></article>
<article><h3>Most Played</h3><p style={{ fontSize: '0.8rem' }}>{stats.episodePlays[0]?.title ?? '—'}</p></article>
</div>
</> </>
)} )}
@@ -584,10 +603,9 @@ export function AnalyticsPanel({
<> <>
<div className="admin-stats-head admin-stats-head--visitors"> <div className="admin-stats-head admin-stats-head--visitors">
<h2>Engagement</h2> <h2>Engagement</h2>
<p>Scroll depth, time on page, audio, and interaction data from consenting visitors.</p> <p>Scroll depth, time on page, audio, and interaction data. Consent-gated.</p>
</div> </div>
{/* Time on page */}
{stats.engagement.timeOnPage?.length > 0 && ( {stats.engagement.timeOnPage?.length > 0 && (
<div style={{ marginBottom: '1.5rem' }}> <div style={{ marginBottom: '1.5rem' }}>
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>Avg. Time on Page</h3> <h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>Avg. Time on Page</h3>
@@ -598,7 +616,7 @@ export function AnalyticsPanel({
{stats.engagement.timeOnPage.slice(0, 15).map((row: { path: string; avgSeconds: number; count: number }) => ( {stats.engagement.timeOnPage.slice(0, 15).map((row: { path: string; avgSeconds: number; count: number }) => (
<tr key={row.path}> <tr key={row.path}>
<td>{row.path}</td> <td>{row.path}</td>
<td>{row.avgSeconds >= 60 ? `${Math.floor(row.avgSeconds / 60)}m ${row.avgSeconds % 60}s` : `${row.avgSeconds}s`}</td> <td>{fmtSec(row.avgSeconds)}</td>
<td>{row.count.toLocaleString()}</td> <td>{row.count.toLocaleString()}</td>
</tr> </tr>
))} ))}
@@ -608,7 +626,6 @@ export function AnalyticsPanel({
</div> </div>
)} )}
{/* Scroll depth */}
{stats.engagement.scrollDepth?.length > 0 && ( {stats.engagement.scrollDepth?.length > 0 && (
<div style={{ marginBottom: '1.5rem' }}> <div style={{ marginBottom: '1.5rem' }}>
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>Scroll Depth</h3> <h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>Scroll Depth</h3>
@@ -631,7 +648,6 @@ export function AnalyticsPanel({
</div> </div>
)} )}
{/* Audio events */}
{stats.engagement.audioEvents?.length > 0 && ( {stats.engagement.audioEvents?.length > 0 && (
<div style={{ marginBottom: '1.5rem' }}> <div style={{ marginBottom: '1.5rem' }}>
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>Audio Engagement</h3> <h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>Audio Engagement</h3>
@@ -639,26 +655,20 @@ export function AnalyticsPanel({
<table className="admin-stats-table"> <table className="admin-stats-table">
<thead><tr><th>Episode</th><th>Completions</th><th>Pauses</th><th>Total Listen</th></tr></thead> <thead><tr><th>Episode</th><th>Completions</th><th>Pauses</th><th>Total Listen</th></tr></thead>
<tbody> <tbody>
{stats.engagement.audioEvents.map((row: { title: string; pauses: number; completions: number; totalListenSeconds: number }) => { {stats.engagement.audioEvents.map((row: { title: string; pauses: number; completions: number; totalListenSeconds: number }) => (
const hrs = Math.floor(row.totalListenSeconds / 3600) <tr key={row.title}>
const mins = Math.floor((row.totalListenSeconds % 3600) / 60) <td style={{ maxWidth: 240, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{row.title}</td>
const listenStr = hrs > 0 ? `${hrs}h ${mins}m` : `${mins}m` <td>{row.completions.toLocaleString()}</td>
return ( <td>{row.pauses.toLocaleString()}</td>
<tr key={row.title}> <td>{fmtSec(row.totalListenSeconds)}</td>
<td style={{ maxWidth: 240, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{row.title}</td> </tr>
<td>{row.completions.toLocaleString()}</td> ))}
<td>{row.pauses.toLocaleString()}</td>
<td>{listenStr}</td>
</tr>
)
})}
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
)} )}
{/* Search queries */}
{stats.engagement.topSearchQueries?.length > 0 && ( {stats.engagement.topSearchQueries?.length > 0 && (
<div style={{ marginBottom: '1.5rem' }}> <div style={{ marginBottom: '1.5rem' }}>
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>Top Search Queries</h3> <h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>Top Search Queries</h3>
@@ -675,7 +685,6 @@ export function AnalyticsPanel({
</div> </div>
)} )}
{/* All link clicks */}
{stats.engagement.topLinkClicks?.length > 0 && ( {stats.engagement.topLinkClicks?.length > 0 && (
<div style={{ marginBottom: '1.5rem' }}> <div style={{ marginBottom: '1.5rem' }}>
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>Link Clicks</h3> <h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>Link Clicks</h3>
@@ -702,7 +711,6 @@ export function AnalyticsPanel({
</div> </div>
)} )}
{/* Outbound clicks */}
{stats.engagement.topOutboundClicks?.length > 0 && ( {stats.engagement.topOutboundClicks?.length > 0 && (
<div style={{ marginBottom: '1.5rem' }}> <div style={{ marginBottom: '1.5rem' }}>
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>Outbound Link Clicks</h3> <h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>Outbound Link Clicks</h3>
@@ -724,7 +732,6 @@ export function AnalyticsPanel({
</div> </div>
)} )}
{/* UTM sources */}
{stats.engagement.topUTMSources?.length > 0 && ( {stats.engagement.topUTMSources?.length > 0 && (
<div style={{ marginBottom: '1.5rem' }}> <div style={{ marginBottom: '1.5rem' }}>
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>UTM Sources</h3> <h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>UTM Sources</h3>
@@ -741,7 +748,6 @@ export function AnalyticsPanel({
</div> </div>
)} )}
{/* 404s */}
{stats.engagement.top404s?.length > 0 && ( {stats.engagement.top404s?.length > 0 && (
<div style={{ marginBottom: '1.5rem' }}> <div style={{ marginBottom: '1.5rem' }}>
<h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>404 Not Found</h3> <h3 style={{ marginBottom: '0.5rem', fontSize: '0.9rem', color: '#b0a48c' }}>404 Not Found</h3>
@@ -763,11 +769,11 @@ export function AnalyticsPanel({
{/* Contact Summary */} {/* Contact Summary */}
<div className="admin-stats-head admin-stats-head--visitors"> <div className="admin-stats-head admin-stats-head--visitors">
<h2>Contact Summary</h2> <h2>Contact Summary</h2>
<p>Submission totals from the contact form.</p> <p>All-time submission totals from the contact form.</p>
</div> </div>
<div className="admin-stats-grid"> <div className="admin-stats-grid">
<article><h3>Total Contact Messages</h3><p>{stats.contactTotals.totalSubmissions.toLocaleString()}</p></article> <article><h3>Total Messages</h3><p>{stats.contactTotals.totalSubmissions.toLocaleString()}</p></article>
<article><h3>Total Bible Questions</h3><p>{stats.contactTotals.totalQuestions.toLocaleString()}</p></article> <article><h3>Bible Questions</h3><p>{stats.contactTotals.totalQuestions.toLocaleString()}</p></article>
</div> </div>
{/* Study Enrollment Funnel */} {/* Study Enrollment Funnel */}
@@ -775,7 +781,7 @@ export function AnalyticsPanel({
<> <>
<div className="admin-stats-head admin-stats-head--visitors"> <div className="admin-stats-head admin-stats-head--visitors">
<h2>Study Enrollment Funnel</h2> <h2>Study Enrollment Funnel</h2>
<p>How many users progress from signup → first visit → first section completed.</p> <p>Signup → first study visit → first section completed.</p>
</div> </div>
<div className="admin-funnel"> <div className="admin-funnel">
{(() => { {(() => {
@@ -805,10 +811,9 @@ export function AnalyticsPanel({
</>)} </>)}
{panelTab === 'system' && (<> {panelTab === 'system' && (<>
{/* Deployment & Cache Status */}
<div className="admin-stats-head admin-stats-head--visitors"> <div className="admin-stats-head admin-stats-head--visitors">
<h2>Deployment &amp; Cache Status</h2> <h2>Deployment &amp; Cache Status</h2>
<p>Current deployment metadata and cache purge health for the live app.</p> <p>Current deployment metadata and cache purge health.</p>
</div> </div>
<div className="admin-stats-grid"> <div className="admin-stats-grid">
<article><h3>Build Commit</h3><p>{opsStatus?.buildCommit ?? 'Not available'}</p></article> <article><h3>Build Commit</h3><p>{opsStatus?.buildCommit ?? 'Not available'}</p></article>
@@ -817,7 +822,6 @@ export function AnalyticsPanel({
<article><h3>Cache Purge</h3><p>{opsStatus?.cachePurge.ok ? 'Healthy' : 'Needs setup'}</p><p>{formatDate(opsStatus?.cachePurge.at ?? null)}</p></article> <article><h3>Cache Purge</h3><p>{opsStatus?.cachePurge.ok ? 'Healthy' : 'Needs setup'}</p><p>{formatDate(opsStatus?.cachePurge.at ?? null)}</p></article>
</div> </div>
{/* Data Management */}
<div className="admin-stats-head admin-stats-head--visitors"> <div className="admin-stats-head admin-stats-head--visitors">
<h2>Data Management</h2> <h2>Data Management</h2>
<p>Export, backup, or retain only recent analytics data.</p> <p>Export, backup, or retain only recent analytics data.</p>
@@ -857,7 +861,6 @@ export function AnalyticsPanel({
</div> </div>
)} )}
{/* Full Data Backup (entire /data folder — server migration) */}
<div className="admin-stats-head admin-stats-head--visitors"> <div className="admin-stats-head admin-stats-head--visitors">
<h2>Full Data Backup</h2> <h2>Full Data Backup</h2>
<p>Download every persisted file — site content, uploads, study accounts, notes, progress, questions, analytics — plus a snapshot of server settings (env-snapshot.env) as one archive, or restore that archive on this or a new server. The archive contains secrets like the admin password and API keys, so keep it private. Restored settings are not applied automatically — copy them into the new server&apos;s environment.</p> <p>Download every persisted file — site content, uploads, study accounts, notes, progress, questions, analytics — plus a snapshot of server settings (env-snapshot.env) as one archive, or restore that archive on this or a new server. The archive contains secrets like the admin password and API keys, so keep it private. Restored settings are not applied automatically — copy them into the new server&apos;s environment.</p>
@@ -869,8 +872,6 @@ export function AnalyticsPanel({
</div> </div>
<div className="admin-restore-row"> <div className="admin-restore-row">
<label htmlFor="full-backup-file">Restore from File</label> <label htmlFor="full-backup-file">Restore from File</label>
{/* No accept filter: iPadOS greys out .tar.gz with one — the server
validates the archive contents before restoring anyway. */}
<input <input
id="full-backup-file" id="full-backup-file"
type="file" type="file"