import { useState } from 'react' import { Line, Bar, Pie, Doughnut } from 'react-chartjs-2' import GeoMaps from './GeoMaps' import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, BarElement, Title, Tooltip, Legend, ArcElement, } from 'chart.js' import type { AdminStats } from '../AdminPage' ChartJS.register( CategoryScale, LinearScale, PointElement, LineElement, BarElement, Title, Tooltip, Legend, ArcElement, ) interface Props { stats: AdminStats | null statsStatus: 'loading' | 'error' | 'ready' statsRange?: '7d' | '30d' | '90d' onRangeChange?: (range: '7d' | '30d' | '90d') => 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 maintenanceMsg: string | null selectedBackup: string backupFiles: Array<{ filename: string; sizeBytes: number; createdAt: string | null; reason: string; adminUpdatedAt: string | null; totalHits: number; totalVisits: number }> selectedBackupPreview: { filename: string; sizeBytes: number; createdAt: string | null; reason: string; adminUpdatedAt: string | null; totalHits: number; totalVisits: number } | null onBackupSelect: (filename: string) => void onRestore: () => void onExport: () => void onBackupNow: () => void onDownloadFullBackup: () => void onImportFullBackup: (file: File) => void fullBackupBusy: boolean onPrune: () => void onClear: () => void onPurgeCache: () => void onDeployHook: () => void onRefreshStatus: () => void } export function AnalyticsPanel({ stats, statsStatus, statsRange = '30d', onRangeChange, opsStatus, formatDate, maskIp, maintenanceMsg, selectedBackup, backupFiles, selectedBackupPreview, onBackupSelect, onRestore, onExport, onBackupNow, onDownloadFullBackup, onImportFullBackup, fullBackupBusy, onPrune, onClear, onPurgeCache, onDeployHook, onRefreshStatus, }: Props) { const [panelTab, setPanelTab] = useState<'traffic' | 'engagement' | 'system'>('traffic') const [chartTab, setChartTab] = useState<'overview' | 'breakdown' | 'geographic' | 'referrers'>('overview') const [expandedVisitor, setExpandedVisitor] = useState(null) const [fullBackupFile, setFullBackupFile] = useState(null) if (statsStatus === 'loading') return

Loading analytics…

if (statsStatus === 'error') return

Failed to load analytics.

if (!stats) return null // 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 const last30DaysBotTotal = stats.last30DaysBotTotal ?? 0 const topPathsReal = stats.topPathsReal ?? stats.topPaths 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), datasets: [ { label: 'Real Visitors', data: last7DaysReal.map((d: { day: string; hits: number }) => d.hits), borderColor: '#c9a84c', backgroundColor: 'rgba(201, 168, 76, 0.1)', tension: 0.4, fill: true, }, { label: 'Bot Traffic', data: last7DaysBot.map((d: { day: string; hits: number }) => d.hits), borderColor: '#666', backgroundColor: 'rgba(102, 102, 102, 0.1)', tension: 0.4, fill: true, }, ], } // 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'], datasets: [{ data: [realHits, botHits], backgroundColor: ['#c9a84c', '#999'], borderColor: '#1a1a15', borderWidth: 2, }], } // Top paths comparison 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}` }).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', }, ], } // 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 = { labels: ['Desktop', 'Mobile', 'Tablet', 'Unknown'], datasets: [{ data: [deviceBreakdown.desktop, deviceBreakdown.mobile, deviceBreakdown.tablet, deviceBreakdown.unknown], backgroundColor: ['#c9a84c', '#a0853d', '#6d5a2e', '#444'], borderColor: '#1a1a15', borderWidth: 2, }], } const chartOptions = { responsive: true, maintainAspectRatio: true, plugins: { 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' }, }, }, } const rangeTotalHits = (stats as AdminStats & { rangeTotalHits?: number }).rangeTotalHits ?? totalHits const rangeLabel = (stats as AdminStats & { rangeLabel?: string }).rangeLabel ?? statsRange return (

Analytics Dashboard

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

{/* Top-level panel tabs */}
{panelTab === 'traffic' && (<> {/* Time-range tabs */} {onRangeChange && (
{(['7d', '30d', '90d'] as const).map(r => ( ))} Showing: {rangeLabel}
)} {/* KPI Grid */}

Total Hits

{rangeTotalHits.toLocaleString()}

{rangeLabel} all requests

Real Traffic

{realHits.toLocaleString()}

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

Bot Traffic

{botHits.toLocaleString()}

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

Last 30 Days

{last30DaysRealTotal.toLocaleString()}

Real hits • {last30DaysBotTotal.toLocaleString()} bots

Unique Visitors

{stats.visitors.uniqueVisitors.toLocaleString()}

Tracked with consent

Returning Rate

{stats.visitors.totalVisits > 0 ? `${Math.round((stats.visitors.returningVisits / stats.visitors.totalVisits) * 100)}%` : '0%'}

{stats.visitors.returningVisits.toLocaleString()} return visits
{/* Chart Tabs */}
{/* Overview Charts */} {chartTab === 'overview' && ( <>

7-Day Trend (Real vs Bot)

Traffic Composition

30-Day Real Visitor Trend

)} {/* Bot & Device Charts */} {chartTab === 'breakdown' && ( <>

Device Breakdown

Device Counts

  • Desktop{deviceBreakdown.desktop.toLocaleString()}
  • Mobile{deviceBreakdown.mobile.toLocaleString()}
  • Tablet{deviceBreakdown.tablet.toLocaleString()}
  • {deviceBreakdown.unknown > 0 &&
  • Unknown{deviceBreakdown.unknown.toLocaleString()}
  • }

Bot Sources

{botReasons.length === 0 ? (

No bot traffic detected.

) : ( )}

Bot Detection Details

{botReasons.length === 0 ? (

No bots detected yet.

) : (
    {botReasons.map((reason: { reason: string; count: number }) => (
  • {reason.reason.replace(/-/g, ' ')} {reason.count.toLocaleString()}
  • ))}
)}
)} {/* Geographic Charts */} {chartTab === 'geographic' && (

Top Pages

)} {/* Referrers Tab */} {chartTab === 'referrers' && (

Top Traffic Sources

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

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

) : (
    {(stats.visitors.topReferrers ?? []).map((item: { referrer: string; count: number }) => (
  • {item.referrer} {item.count.toLocaleString()}
  • ))}
)}
)} {/* Recent Visitors Table */}

Visitor Details

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

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

Total Visits

{stats.visitors.totalVisits.toLocaleString()}

Unique Visitors

{stats.visitors.uniqueVisitors.toLocaleString()}

Returning Visits

{stats.visitors.returningVisits.toLocaleString()}

First Visit

{formatDate(stats.visitors.firstVisitAt)}

Top Countries

    {stats.visitors.topCountries.map((item: { name: string; hits: number }) => (
  • {item.name}{item.hits.toLocaleString()}
  • ))}

Top States

    {stats.visitors.topStates.map((item: { name: string; hits: number }) => (
  • {item.name}{item.hits.toLocaleString()}
  • ))}

Recent Visitor Log

{stats.visitors.recentVisits.length === 0 ? (

No visitor records yet.

) : (
{stats.visitors.recentVisits.map(row => { const rowKey = `${row.visitorId}-${row.at}` 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' return (
{isExpanded && (
IP {maskIp(row.ip)} {row.studyUser && Username {row.studyUser.username}} {row.referrer && Referrer {row.referrer}} Location {[row.city, row.county, row.state, row.country].filter(v => v && v !== 'Unknown').join(', ') || '—'}
{history.length > 0 ? ( <>

Page history ({history.length} pages)

    {[...history].reverse().map((entry, i) => (
  1. {formatDate(entry.at)} {entry.path} {entry.referrer && from {entry.referrer}}
  2. ))}
) : (

No page history yet — recorded after consent is given.

)}
)}
) })}
)}
)} {panelTab === 'engagement' && (<> {/* Episode Plays */} {stats.episodePlays && stats.episodePlays.length > 0 && ( <>

Episode Plays

Tracked each time a visitor hits play on the audio player. One count per player mount.

ep.title.length > 40 ? ep.title.slice(0, 40) + '…' : ep.title), datasets: [{ label: 'Total Plays', data: stats.episodePlays.slice(0, 12).map((ep: { title: string; total: number }) => ep.total), backgroundColor: '#c9a84c', borderColor: '#8a6e28', borderWidth: 1, borderRadius: 4, }], }} options={{ indexAxis: 'y' as const, responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { callbacks: { label: (ctx) => ` ${ctx.parsed.x} plays` } }, }, scales: { x: { ticks: { color: '#b0a48c', font: { size: 11 } }, grid: { color: 'rgba(42,37,24,0.6)' } }, y: { ticks: { color: '#f0ead8', font: { size: 11 } }, grid: { display: false } }, }, }} style={{ height: `${Math.max(180, stats.episodePlays.slice(0, 12).length * 36)}px` }} />

Total Episode Plays

{stats.episodePlays.reduce((sum: number, ep: { total: number }) => sum + ep.total, 0).toLocaleString()}

Episodes Played

{stats.episodePlays.length.toLocaleString()}

Most Played

{stats.episodePlays[0]?.title ?? '—'}

)} {/* Engagement */} {stats.engagement && ( <>

Engagement

Scroll depth, time on page, audio, and interaction data from consenting visitors.

{/* Time on page */} {stats.engagement.timeOnPage?.length > 0 && (

Avg. Time on Page

{stats.engagement.timeOnPage.slice(0, 15).map((row: { path: string; avgSeconds: number; count: number }) => ( ))}
PageAvg TimeSessions
{row.path} {row.avgSeconds >= 60 ? `${Math.floor(row.avgSeconds / 60)}m ${row.avgSeconds % 60}s` : `${row.avgSeconds}s`} {row.count.toLocaleString()}
)} {/* Scroll depth */} {stats.engagement.scrollDepth?.length > 0 && (

Scroll Depth

{stats.engagement.scrollDepth.slice(0, 15).map((row: { path: string; 25?: number; 50?: number; 75?: number; 90?: number }) => ( ))}
Page25%50%75%90%
{row.path} {(row[25] ?? 0).toLocaleString()} {(row[50] ?? 0).toLocaleString()} {(row[75] ?? 0).toLocaleString()} {(row[90] ?? 0).toLocaleString()}
)} {/* Audio events */} {stats.engagement.audioEvents?.length > 0 && (

Audio Engagement

{stats.engagement.audioEvents.map((row: { title: string; pauses: number; completions: number; totalListenSeconds: number }) => { const hrs = Math.floor(row.totalListenSeconds / 3600) const mins = Math.floor((row.totalListenSeconds % 3600) / 60) const listenStr = hrs > 0 ? `${hrs}h ${mins}m` : `${mins}m` return ( ) })}
EpisodeCompletionsPausesTotal Listen
{row.title} {row.completions.toLocaleString()} {row.pauses.toLocaleString()} {listenStr}
)} {/* Search queries */} {stats.engagement.topSearchQueries?.length > 0 && (

Top Search Queries

{stats.engagement.topSearchQueries.map((row: { query: string; count: number }) => ( ))}
QuerySearches
{row.query}{row.count.toLocaleString()}
)} {/* All link clicks */} {stats.engagement.topLinkClicks?.length > 0 && (

Link Clicks

{stats.engagement.topLinkClicks.map((row: { url: string; count: number; internal: boolean }) => ( ))}
DestinationTypeClicks
{row.internal ? {row.url} : {row.url}} {row.internal ? 'internal' : 'external'} {row.count.toLocaleString()}
)} {/* Outbound clicks */} {stats.engagement.topOutboundClicks?.length > 0 && (

Outbound Link Clicks

{stats.engagement.topOutboundClicks.map((row: { url: string; count: number }) => ( ))}
URLClicks
{row.url} {row.count.toLocaleString()}
)} {/* UTM sources */} {stats.engagement.topUTMSources?.length > 0 && (

UTM Sources

{stats.engagement.topUTMSources.map((row: { source: string; count: number }) => ( ))}
SourceVisits
{row.source}{row.count.toLocaleString()}
)} {/* 404s */} {stats.engagement.top404s?.length > 0 && (

404 Not Found

{stats.engagement.top404s.map((row: { path: string; count: number }) => ( ))}
PathHits
{row.path}{row.count.toLocaleString()}
)} )} {/* Contact Summary */}

Contact Summary

Submission totals from the contact form.

Total Contact Messages

{stats.contactTotals.totalSubmissions.toLocaleString()}

Total Bible Questions

{stats.contactTotals.totalQuestions.toLocaleString()}

{/* Study Enrollment Funnel */} {stats.studyEnrollment?.funnel && ( <>

Study Enrollment Funnel

How many users progress from signup → first visit → first section completed.

{(() => { const { signups, firstVisit, firstCompletion } = stats.studyEnrollment.funnel! const steps = [ { label: 'Signed Up', count: signups, pct: 100 }, { label: 'Visited a Study', count: firstVisit, pct: signups > 0 ? Math.round((firstVisit / signups) * 100) : 0 }, { label: 'Completed a Section', count: firstCompletion, pct: signups > 0 ? Math.round((firstCompletion / signups) * 100) : 0 }, ] return steps.map((step, i) => (
{step.label} {step.count.toLocaleString()} {step.pct}%
)) })()}
)} )} {panelTab === 'system' && (<> {/* Deployment & Cache Status */}

Deployment & Cache Status

Current deployment metadata and cache purge health for the live app.

Build Commit

{opsStatus?.buildCommit ?? 'Not available'}

Build Number

{opsStatus?.buildNumber ?? 'Not available'}

Deployed At

{formatDate(opsStatus?.deployedAt ?? null)}

Cache Purge

{opsStatus?.cachePurge.ok ? 'Healthy' : 'Needs setup'}

{formatDate(opsStatus?.cachePurge.at ?? null)}

{/* Data Management */}

Data Management

Export, backup, or retain only recent analytics data.

Hit Stats Write

{stats.writeStatus.hitStats.ok ? 'Healthy' : 'Error'}

{formatDate(stats.writeStatus.hitStats.at)}

Visitor Stats Write

{stats.writeStatus.visitorStats.ok ? 'Healthy' : 'Error'}

{formatDate(stats.writeStatus.visitorStats.at)}

Backup Status

{stats.writeStatus.backups.ok ? 'Healthy' : 'Error'}

{formatDate(stats.writeStatus.backups.at)}

{selectedBackupPreview && (

Restore Preview

Backup: {selectedBackupPreview.filename}

Created: {formatDate(selectedBackupPreview.createdAt)}

Size: {(selectedBackupPreview.sizeBytes / 1024).toFixed(1)} KB

Total Hits: {selectedBackupPreview.totalHits.toLocaleString()}

Total Visits: {selectedBackupPreview.totalVisits.toLocaleString()}

)} {/* Full Data Backup (entire /data folder — server migration) */}

Full Data Backup

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's environment.

{/* No accept filter: iPadOS greys out .tar.gz with one — the server validates the archive contents before restoring anyway. */} setFullBackupFile(e.target.files?.[0] ?? null)} disabled={fullBackupBusy} />
{maintenanceMsg &&

{maintenanceMsg}

} )}
) }