From 4002f1ee93b10798d92b1445a0605011d4e4035c Mon Sep 17 00:00:00 2001 From: nmemmert Date: Thu, 17 Sep 2026 16:27:53 -0400 Subject: [PATCH] Analytics overhaul: privacy fixes, All Time range, readability improvements; v2.0.29 - 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 --- package-lock.json | 40 +++- package.json | 3 +- server/routes/analytics.js | 134 ++++++++---- src/AdminPage.tsx | 8 +- src/analytics.ts | 44 ++-- src/components/AnalyticsPanel.tsx | 335 +++++++++++++++--------------- 6 files changed, 333 insertions(+), 231 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5d86ad7..7e05b50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "mammoth": "^1.12.0", "multer": "^2.3.0", "otplib": "^13.4.0", + "prop-types": "^15.8.1", "qrcode": "^1.5.4", "react": "^19.2.4", "react-chartjs-2": "^5.2.0", @@ -6898,7 +6899,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, "license": "MIT" }, "node_modules/js-yaml": { @@ -7346,6 +7346,18 @@ "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": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz", @@ -8470,6 +8482,15 @@ "dev": true, "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": { "version": "1.13.4", "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==", "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": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", @@ -9250,6 +9282,12 @@ "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": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", diff --git a/package.json b/package.json index 52a531e..c22aca9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "siteforge", "private": true, - "version": "2.0.27", + "version": "2.0.29", "type": "module", "scripts": { "dev": "vite", @@ -26,6 +26,7 @@ "mammoth": "^1.12.0", "multer": "^2.3.0", "otplib": "^13.4.0", + "prop-types": "^15.8.1", "qrcode": "^1.5.4", "react": "^19.2.4", "react-chartjs-2": "^5.2.0", diff --git a/server/routes/analytics.js b/server/routes/analytics.js index 2fa1027..49bf514 100644 --- a/server/routes/analytics.js +++ b/server/routes/analytics.js @@ -22,12 +22,12 @@ import { getStudyCatalog, } from '../study-helpers.js' -async function resolveGeo(ip) { +async function resolveGeo(ip, ipHash) { if (!ip || isPrivateOrLocalIp(ip)) { 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 const providers = [ @@ -62,7 +62,7 @@ async function resolveGeo(ip) { try { const geo = await provider() if (geo) { - state.visitorStats.geoCacheByIp[ip] = geo + state.visitorStats.geoCacheByIp[ipHash] = geo queueVisitorStatsWrite() return geo } @@ -70,17 +70,33 @@ async function resolveGeo(ip) { } const fallback = { country: 'Unknown', state: 'Unknown', county: 'Unknown', city: 'Unknown' } - state.visitorStats.geoCacheByIp[ip] = fallback + state.visitorStats.geoCacheByIp[ipHash] = fallback queueVisitorStatsWrite() 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) let visitorId = cookies[VISITOR_COOKIE] if (!visitorId) { 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() @@ -95,12 +111,11 @@ export async function recordVisitor(req, res, overridePath = null, overrideRefer : null const ipHash = createHash('sha256').update(ip).digest('hex') - const geo = await resolveGeo(ip) const existingIdByIp = state.visitorStats.ipHashIndex[ipHash] if (existingIdByIp && existingIdByIp !== visitorId) { 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] @@ -123,8 +138,12 @@ export async function recordVisitor(req, res, overridePath = null, overrideRefer 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] = { - visitorId, ip, ipHash, + visitorId, ipHash, firstSeenAt: existingVisitor?.firstSeenAt ?? nowIso, lastSeenAt: nowIso, visitCount: nextVisitCount, @@ -141,7 +160,8 @@ export async function recordVisitor(req, res, overridePath = null, overrideRefer state.visitorStats.firstVisitAt = state.visitorStats.firstVisitAt ?? nowIso state.visitorStats.lastVisitAt = nowIso 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, returningVisitor: isReturning, visitCount: nextVisitCount, ...(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) 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) { @@ -160,6 +197,10 @@ export function register(app) { app.post('/api/analytics/play', (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 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 } recordEpisodePlay(title) @@ -179,56 +220,63 @@ export function register(app) { res.json({ ok: true }) }) - app.post('/api/analytics/pageview', async (req, res) => { - if (isValidAdminSession(req)) { - res.json({ ok: false, reason: 'admin' }); return - } - if (!hasVisitorConsent(req)) { - res.json({ ok: false, reason: 'no-consent' }); return - } + app.post('/api/analytics/pageview', (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 - } + if (isBot) { 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 rawReferrer = typeof req.body?.referrer === 'string' ? req.body.referrer : '' recordHit(rawPath, false) queueHitStatsWrite() - await recordVisitor(req, res, rawPath, rawReferrer) + recordVisitor(req, res, rawPath, rawReferrer) res.json({ ok: true }) }) 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 rangeDays = rangeParam === '7d' ? 7 : rangeParam === '90d' ? 90 : 30 - const rangeLabel = rangeParam === '7d' ? '7d' : rangeParam === '90d' ? '90d' : '30d' + const isAllTime = rangeParam === 'all' + 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 - const cutoffDate = (() => { + // Cutoff date (null = all-time) + const cutoffDate = isAllTime ? null : (() => { const d = new Date() - d.setDate(d.getDate() - (rangeDays - 1)) + d.setDate(d.getDate() - ((rangeDays ?? 30) - 1)) 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([ ...Object.keys(state.hitStats.byDayReal ?? {}), ...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 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 rangeTotalHits = rangeRealHits + rangeBotHits - // Filter per-path stats by range — approximate using recent visitor rows scoped to range - const rangeVisitorRows = state.visitorStats.recentVisits.filter(row => { - if (!row.at) return false // exclude rows with no timestamp from range views - return row.at >= cutoffDate - }) + // Trend chart data — one point per day for the selected range + const trendData = filteredDayKeys.map(day => ({ + day, + 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 rangePathCountsBot = {} @@ -257,9 +305,6 @@ export function register(app) { const botReasons = Object.entries(state.hitStats.botReasons ?? {}) .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) const recentVisitorRows = rangeVisitorRows.slice(0, 100).map(row => { 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 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 funnelFirstVisit = state.studyUsers.filter(u => u.firstVisitAt).length const funnelFirstCompletion = state.studyUsers.filter(u => u.firstCompletionAt).length @@ -307,6 +351,8 @@ export function register(app) { rangeTotalHits, rangeLabel, rangeDays, + isAllTime, + trendData, // All-time totals for reference allTimeRealHits: state.hitStats.realHits ?? 0, allTimeBotHits: state.hitStats.botHits ?? 0, @@ -323,13 +369,13 @@ export function register(app) { returningVisits: state.visitorStats.returningVisits, firstVisitAt: state.visitorStats.firstVisitAt, lastVisitAt: state.visitorStats.lastVisitAt, - topCountries: buildTopLocations(allRangeRows, 'country'), - topStates: buildTopLocations(allRangeRows, 'state'), - topCounties: buildTopLocations(allRangeRows, 'county'), - topCities: buildTopLocations(allRangeRows, 'city'), + topCountries: buildTopLocations(rangeVisitorRows, 'country'), + topStates: buildTopLocations(rangeVisitorRows, 'state'), + topCounties: buildTopLocations(rangeVisitorRows, 'county'), + topCities: buildTopLocations(rangeVisitorRows, 'city'), deviceBreakdown: (() => { 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' counts[d] = (counts[d] ?? 0) + 1 } @@ -337,7 +383,7 @@ export function register(app) { })(), topReferrers: (() => { const counts = {} - for (const row of allRangeRows) { + for (const row of rangeVisitorRows) { if (!row.referrer) continue counts[row.referrer] = (counts[row.referrer] ?? 0) + 1 } diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index 0a073c7..52e8b5c 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -546,7 +546,7 @@ export interface AdminStats { recentVisits: Array<{ at: string visitorId: string - ip: string + ipHash?: string path: string referrer?: string device?: string @@ -1531,7 +1531,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { const [navOpenGroups, setNavOpenGroups] = useState>(new Set()) const [stats, setStats] = useState(null) 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 [opsMsg, setOpsMsg] = useState('') const [backupFiles, setBackupFiles] = useState([]) @@ -1883,7 +1883,7 @@ const [dashboardNow, setDashboardNow] = useState(() => new Date()) }) }, [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}`) if (!r.ok) throw new Error('Could not refresh stats') const data = await r.json() @@ -5592,7 +5592,7 @@ async function handleAssetUpload(event: ChangeEvent) { stats={stats} statsStatus={statsStatus} statsRange={statsRange} - onRangeChange={async (range) => { + onRangeChange={async (range: '7d' | '30d' | '90d' | 'all') => { setStatsRange(range) setStatsStatus('loading') try { await reloadStats(range) } catch { setStatsStatus('error') } diff --git a/src/analytics.ts b/src/analytics.ts index b16a6a6..f64e8d9 100644 --- a/src/analytics.ts +++ b/src/analytics.ts @@ -2,11 +2,28 @@ import { useEffect, useRef } from 'react' import { useLocation } from 'react-router-dom' const CONSENT_KEY = 'vbn_analytics_consent_choice' +const UTM_PENDING_KEY = 'vbn_pending_utm' function hasConsent(): boolean { 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 = {} + 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): void { if (!hasConsent()) return fetch('/api/analytics/event', { @@ -69,22 +86,21 @@ export function useTimeOnPage() { }, [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() { - const captured = useRef(false) - useEffect(() => { - if (captured.current || !hasConsent()) return - const params = new URLSearchParams(window.location.search) - const utm: Record = {} - 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) return - captured.current = true - sendEvent('utm', { path: window.location.pathname, ...utm }) - }, []) + if (!hasConsent()) return + try { + const raw = sessionStorage.getItem(UTM_PENDING_KEY) + if (!raw) return + const utm = JSON.parse(raw) as Record + sessionStorage.removeItem(UTM_PENDING_KEY) + sendEvent('utm', utm) + } catch { /* ignore */ } + }) } // --- All link clicks (internal + external) --- diff --git a/src/components/AnalyticsPanel.tsx b/src/components/AnalyticsPanel.tsx index 3429eb3..c446c9f 100644 --- a/src/components/AnalyticsPanel.tsx +++ b/src/components/AnalyticsPanel.tsx @@ -27,11 +27,13 @@ ChartJS.register( ArcElement, ) +type StatsRange = '7d' | '30d' | '90d' | 'all' + interface Props { stats: AdminStats | null statsStatus: 'loading' | 'error' | 'ready' - statsRange?: '7d' | '30d' | '90d' - onRangeChange?: (range: '7d' | '30d' | '90d') => void + statsRange?: StatsRange + 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 formatDate: (date: string | null) => string maskIp: (ip: string) => string @@ -53,6 +55,37 @@ interface Props { onRefreshStatus: () => void } +const RANGE_LABELS: Record = { + '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 = {} + 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({ stats, statsStatus, @@ -60,7 +93,6 @@ export function AnalyticsPanel({ onRangeChange, opsStatus, formatDate, - maskIp, maintenanceMsg, selectedBackup, backupFiles, @@ -90,7 +122,6 @@ export function AnalyticsPanel({ // Normalize fields that may be missing from older data const realHits = stats.realHits ?? 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 last7DaysBot = stats.last7DaysBot ?? stats.last7Days.map((d: { day: string; hits: number }) => ({ day: d.day, hits: 0 })) const last30DaysRealTotal = stats.last30DaysRealTotal ?? 0 @@ -99,51 +130,46 @@ export function AnalyticsPanel({ const topPathsBot = stats.topPathsBot ?? [] const botReasons = stats.botReasons ?? [] - // Chart data - Daily trend - const dailyChartData = { - labels: stats.last7Days.map((d: { day: string; hits: number }) => d.day), + const rangeTotalHits = (stats as AdminStats & { rangeTotalHits?: number }).rangeTotalHits ?? stats.totalHits + const rangeLabel = (stats as AdminStats & { rangeLabel?: string }).rangeLabel ?? RANGE_LABELS[statsRange] + 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: [ { label: 'Real Visitors', - data: last7DaysReal.map((d: { day: string; hits: number }) => d.hits), + data: trendPoints.map(d => d.real), borderColor: '#c9a84c', backgroundColor: 'rgba(201, 168, 76, 0.1)', - tension: 0.4, + tension: 0.3, fill: true, + pointRadius: trendPoints.length > 90 ? 0 : 2, }, { label: 'Bot Traffic', - data: last7DaysBot.map((d: { day: string; hits: number }) => d.hits), + data: trendPoints.map(d => d.bot), borderColor: '#666', backgroundColor: 'rgba(102, 102, 102, 0.1)', - tension: 0.4, + tension: 0.3, 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) const trafficRatioData = { 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 realPath = topPathsReal[i]?.path || '' - const botPath = topPathsBot[i]?.path || '' - return realPath || botPath || `Path ${i + 1}` + return topPathsReal[i]?.path || topPathsBot[i]?.path || `Path ${i + 1}` }).slice(0, 8) const topPathsChartData = { labels: topPathsLabels, datasets: [ - { - label: 'Real Hits', - 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', - }, + { label: 'Real Hits', data: topPathsLabels.map((_l, i) => topPathsReal[i]?.hits || 0), backgroundColor: '#c9a84c' }, + { label: 'Bot Hits', data: topPathsLabels.map((_l, 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) const deviceBreakdown = stats.visitors.deviceBreakdown ?? { mobile: 0, desktop: 0, tablet: 0, unknown: 0 } const deviceChartData = { @@ -209,41 +221,22 @@ export function AnalyticsPanel({ responsive: true, maintainAspectRatio: true, plugins: { - legend: { - labels: { - color: '#f0ead8', - font: { size: 12 }, - }, - }, - tooltip: { - backgroundColor: '#2a2518', - titleColor: '#f0ead8', - bodyColor: '#f0ead8', - borderColor: '#c9a84c', - borderWidth: 1, - }, + legend: { labels: { color: '#f0ead8', font: { size: 12 } } }, + tooltip: { backgroundColor: '#2a2518', titleColor: '#f0ead8', bodyColor: '#f0ead8', borderColor: '#c9a84c', borderWidth: 1 }, }, scales: { - y: { - beginAtZero: true, - ticks: { color: '#7a7060' }, - grid: { color: '#2a2518' }, - }, - x: { - ticks: { color: '#7a7060' }, - grid: { color: '#2a2518' }, - }, + y: { beginAtZero: true, ticks: { color: '#7a7060' }, grid: { color: '#2a2518' } }, + x: { ticks: { color: '#7a7060', maxTicksLimit: 12 }, grid: { color: '#2a2518' } }, }, } - const rangeTotalHits = (stats as AdminStats & { rangeTotalHits?: number }).rangeTotalHits ?? totalHits - const rangeLabel = (stats as AdminStats & { rangeLabel?: string }).rangeLabel ?? statsRange + const rangeRealVisitors = stats.visitors.recentVisits.length return (

Analytics Dashboard

-

Real-time insights into site traffic, bot detection, visitor behavior, and data management.

+

Site traffic, bot detection, visitor behavior, and data management.

{/* Top-level panel tabs */} @@ -254,10 +247,10 @@ export function AnalyticsPanel({ {panelTab === 'traffic' && (<> - {/* Time-range tabs */} + {/* Time-range selector */} {onRangeChange && ( -
- {(['7d', '30d', '90d'] as const).map(r => ( +
+ {(['7d', '30d', '90d', 'all'] as StatsRange[]).map(r => ( ))} - - Showing: {rangeLabel} -
)} {/* KPI Grid */}
-

Total Hits

-

{rangeTotalHits.toLocaleString()}

- {rangeLabel} all requests -
-
-

Real Traffic

+

Real Hits

{realHits.toLocaleString()}

- {rangeTotalHits > 0 ? ((realHits / rangeTotalHits) * 100).toFixed(1) : '0.0'}% of total + {rangeLabel}

Bot Traffic

{botHits.toLocaleString()}

- {rangeTotalHits > 0 ? ((botHits / rangeTotalHits) * 100).toFixed(1) : '0.0'}% of total + {rangeTotalHits > 0 ? `${((botHits / rangeTotalHits) * 100).toFixed(1)}% of total` : '0% of total'} +
+
+

All-Time Real Hits

+

{((stats as AdminStats & { allTimeRealHits?: number }).allTimeRealHits ?? stats.totalHits).toLocaleString()}

+ Since {formatDate(stats.firstHitAt)}

Last 30 Days

{last30DaysRealTotal.toLocaleString()}

- Real hits • {last30DaysBotTotal.toLocaleString()} bots + Real hits · {last30DaysBotTotal.toLocaleString()} bots

Unique Visitors

@@ -329,19 +319,23 @@ export function AnalyticsPanel({ {chartTab === 'overview' && ( <>
-
-

7-Day Trend (Real vs Bot)

- -
-
-

Traffic Composition

- +
+

{trendTitle}

+
-
-

30-Day Real Visitor Trend

- +
+

Traffic Composition ({rangeLabel})

+ +
+
+

Top Pages ({rangeLabel})

+
    + {topPathsReal.slice(0, 8).map((item: { path: string; hits: number }) => ( +
  • {item.path}{item.hits.toLocaleString()}
  • + ))} +
@@ -353,7 +347,7 @@ export function AnalyticsPanel({

Device Breakdown

- +

Device Counts

@@ -371,7 +365,7 @@ export function AnalyticsPanel({ {botReasons.length === 0 ? (

No bot traffic detected.

) : ( - + )}
@@ -390,20 +384,40 @@ export function AnalyticsPanel({ )}
+
+
+

Top Pages — Real vs Bot ({rangeLabel})

+ +
+
)} {/* Geographic Charts */} {chartTab === 'geographic' && (
-
-

Top Pages

- -
+
+
+

Top Cities

+
    + {(stats.visitors.topCities ?? []).slice(0, 10).map((item: { name: string; hits: number }) => ( +
  • {item.name}{item.hits.toLocaleString()}
  • + ))} +
+
+
+

Top Countries

+
    + {stats.visitors.topCountries.slice(0, 10).map((item: { name: string; hits: number }) => ( +
  • {item.name}{item.hits.toLocaleString()}
  • + ))} +
+
+
)} @@ -411,7 +425,7 @@ export function AnalyticsPanel({ {chartTab === 'referrers' && (
-

Top Traffic Sources

+

Top Traffic Sources ({rangeLabel})

{(stats.visitors.topReferrers ?? []).length === 0 ? (

No referrer data yet. Referrers are captured on SPA navigations after consent.

) : ( @@ -428,16 +442,16 @@ export function AnalyticsPanel({
)} - {/* Recent Visitors Table */} + {/* Recent Visitors */}
-

Visitor Details

-

Real-time tracking of visitor activity, geography, and returning behavior.

+

Visitor Log

+

Activity for {rangeLabel.toLowerCase()} · {rangeRealVisitors} records shown.

-

Privacy: visitor analytics only run after cookie consent. IPs below are masked.

+

Analytics only run after cookie consent. Visitor IDs are pseudonymous hash prefixes.

-

Total Visits

{stats.visitors.totalVisits.toLocaleString()}

-

Unique Visitors

{stats.visitors.uniqueVisitors.toLocaleString()}

+

Total Visits

{stats.visitors.totalVisits.toLocaleString()}

All time
+

Unique Visitors

{stats.visitors.uniqueVisitors.toLocaleString()}

All time

Returning Visits

{stats.visitors.returningVisits.toLocaleString()}

First Visit

{formatDate(stats.visitors.firstVisitAt)}

@@ -472,6 +486,9 @@ export function AnalyticsPanel({ const isExpanded = expandedVisitor === rowKey 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 visitorLabel = row.studyUser + ? (row.studyUser.displayName || row.studyUser.username) + : `Visitor ${(row.ipHash ?? row.visitorId).slice(0, 8)}` return (