Files
Siteforge/src/components/AnalyticsPanel.tsx
T

448 lines
18 KiB
TypeScript

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'
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
onPrune: () => void
onClear: () => void
onPurgeCache: () => void
onDeployHook: () => void
onRefreshStatus: () => void
}
export function AnalyticsPanel({
stats,
statsStatus,
opsStatus,
formatDate,
maskIp,
maintenanceMsg,
selectedBackup,
backupFiles,
selectedBackupPreview,
onBackupSelect,
onRestore,
onExport,
onBackupNow,
onPrune,
onClear,
onPurgeCache,
onDeployHook,
onRefreshStatus,
}: Props) {
const [chartTab, setChartTab] = useState<'overview' | 'breakdown' | 'geographic'>('overview')
if (statsStatus === 'loading') return <p className="admin-stats-note">Loading analytics</p>
if (statsStatus === 'error') return <p className="admin-stats-note">Failed to load analytics.</p>
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',
},
],
}
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' },
},
},
}
return (
<section className="admin-panel-section" aria-label="Analytics">
<div className="admin-panel-head">
<h2>Analytics Dashboard</h2>
<p>Real-time insights into site traffic, bot detection, visitor behavior, and data management.</p>
</div>
{/* KPI Grid */}
<div className="admin-stats-grid">
<article>
<h3>Total Hits</h3>
<p>{stats.totalHits.toLocaleString()}</p>
<small>All page requests</small>
</article>
<article>
<h3>Real Traffic</h3>
<p>{realHits.toLocaleString()}</p>
<small>{totalHits > 0 ? ((realHits / totalHits) * 100).toFixed(1) : '0.0'}% of total</small>
</article>
<article>
<h3>Bot Traffic</h3>
<p>{botHits.toLocaleString()}</p>
<small>{totalHits > 0 ? ((botHits / totalHits) * 100).toFixed(1) : '0.0'}% of total</small>
</article>
<article>
<h3>Last 30 Days</h3>
<p>{last30DaysRealTotal.toLocaleString()}</p>
<small>Real hits {last30DaysBotTotal.toLocaleString()} bots</small>
</article>
<article>
<h3>Unique Visitors</h3>
<p>{stats.visitors.uniqueVisitors.toLocaleString()}</p>
<small>Tracked with consent</small>
</article>
<article>
<h3>Returning Rate</h3>
<p>{stats.visitors.totalVisits > 0 ? `${Math.round((stats.visitors.returningVisits / stats.visitors.totalVisits) * 100)}%` : '0%'}</p>
<small>{stats.visitors.returningVisits.toLocaleString()} return visits</small>
</article>
</div>
{/* Chart Tabs */}
<div className="admin-actions admin-actions--maintenance" style={{ marginBottom: '1.5rem' }}>
<button type="button" className={`btn-admin-reset${chartTab === 'overview' ? ' btn-admin-reset--active' : ''}`} onClick={() => setChartTab('overview')}>Overview</button>
<button type="button" className={`btn-admin-reset${chartTab === 'breakdown' ? ' btn-admin-reset--active' : ''}`} onClick={() => setChartTab('breakdown')}>Bot Breakdown</button>
<button type="button" className={`btn-admin-reset${chartTab === 'geographic' ? ' btn-admin-reset--active' : ''}`} onClick={() => setChartTab('geographic')}>Geographic</button>
</div>
{/* Overview Charts */}
{chartTab === 'overview' && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(400px, 1fr))', gap: '2rem', marginBottom: '2rem' }}>
<div style={{ backgroundColor: '#1a1a15', padding: '1.5rem', borderRadius: '0.5rem', border: '1px solid #2a2518' }}>
<h3 style={{ marginBottom: '1rem' }}>7-Day Trend (Real vs Bot)</h3>
<Line data={dailyChartData} options={chartOptions} />
</div>
<div style={{ backgroundColor: '#1a1a15', padding: '1.5rem', borderRadius: '0.5rem', border: '1px solid #2a2518' }}>
<h3 style={{ marginBottom: '1rem' }}>Traffic Composition</h3>
<Doughnut data={trafficRatioData} options={{...chartOptions, plugins: {...chartOptions.plugins}}} />
</div>
</div>
)}
{/* Bot Breakdown Charts */}
{chartTab === 'breakdown' && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(400px, 1fr))', gap: '2rem', marginBottom: '2rem' }}>
<div style={{ backgroundColor: '#1a1a15', padding: '1.5rem', borderRadius: '0.5rem', border: '1px solid #2a2518' }}>
<h3 style={{ marginBottom: '1rem' }}>Bot Sources</h3>
{botReasons.length === 0 ? (
<p className="admin-stats-note">No bot traffic detected.</p>
) : (
<Pie data={botPieData} options={{...chartOptions, plugins: {...chartOptions.plugins}}} />
)}
</div>
<div style={{ backgroundColor: '#1a1a15', padding: '1.5rem', borderRadius: '0.5rem', border: '1px solid #2a2518' }}>
<h3 style={{ marginBottom: '1rem' }}>Bot Detection Details</h3>
{botReasons.length === 0 ? (
<p className="admin-stats-note">No bots detected yet.</p>
) : (
<ul style={{ listStyle: 'none', padding: 0 }}>
{botReasons.map((reason: { reason: string; count: number }) => (
<li key={reason.reason} style={{ padding: '0.5rem 0', display: 'flex', justifyContent: 'space-between', borderBottom: '1px solid #2a2518' }}>
<span>{reason.reason.replace(/-/g, ' ')}</span>
<strong style={{ color: '#c9a84c' }}>{reason.count.toLocaleString()}</strong>
</li>
))}
</ul>
)}
</div>
</div>
)}
{/* Geographic Charts */}
{chartTab === 'geographic' && (
<div style={{ marginBottom: '2rem' }}>
<div style={{ backgroundColor: '#1a1a15', padding: '1.5rem', borderRadius: '0.5rem', border: '1px solid #2a2518', marginBottom: '2rem' }}>
<h3 style={{ marginBottom: '1rem' }}>Top Pages</h3>
<Bar data={topPathsChartData} options={{...chartOptions, indexAxis: 'y' as const}} />
</div>
<GeoMaps
topCountries={stats.visitors.topCountries}
topStates={stats.visitors.topStates}
/>
</div>
)}
{/* Recent Visitors Table */}
<div className="admin-stats-head admin-stats-head--visitors" style={{ marginTop: '2rem' }}>
<h2>Visitor Details</h2>
<p>Real-time tracking of visitor activity, geography, and returning behavior.</p>
</div>
<p className="admin-privacy-note">Privacy: visitor analytics only run after cookie consent. IPs below are masked.</p>
<div className="admin-stats-grid" style={{ marginBottom: '1.5rem' }}>
<article><h3>Total Visits</h3><p>{stats.visitors.totalVisits.toLocaleString()}</p></article>
<article><h3>Unique Visitors</h3><p>{stats.visitors.uniqueVisitors.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>
</div>
<div className="admin-stats-lists">
<div>
<h3>Top Countries</h3>
<ul>
{stats.visitors.topCountries.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 States</h3>
<ul>
{stats.visitors.topStates.map((item: { name: string; hits: number }) => (
<li key={item.name}><span>{item.name}</span><strong>{item.hits.toLocaleString()}</strong></li>
))}
</ul>
</div>
</div>
<div className="admin-visits-table-wrap">
<h3>Recent Visitor Log</h3>
{stats.visitors.recentVisits.length === 0 ? (
<p className="admin-stats-note">No visitor records yet.</p>
) : (
<div className="admin-visits-table-scroll">
<table className="admin-visits-table">
<thead>
<tr>
<th>Time</th>
<th>IP</th>
<th>Country</th>
<th>Path</th>
<th>Returning</th>
<th>Visit #</th>
</tr>
</thead>
<tbody>
{stats.visitors.recentVisits.map(row => (
<tr key={`${row.visitorId}-${row.at}`}>
<td>{formatDate(row.at)}</td>
<td><code style={{ fontSize: '0.8rem', color: '#aaa' }}>{maskIp(row.ip)}</code></td>
<td>{row.country || '—'}</td>
<td style={{ maxWidth: '220px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={row.path}>{row.path}</td>
<td>
<span style={{
display: 'inline-block',
padding: '0.1rem 0.5rem',
borderRadius: '999px',
fontSize: '0.75rem',
backgroundColor: row.returningVisitor ? '#1a3a1a' : '#1a1a15',
color: row.returningVisitor ? '#6dbf6d' : '#888',
border: `1px solid ${row.returningVisitor ? '#2a5a2a' : '#333'}`,
}}>
{row.returningVisitor ? 'Returning' : 'New'}
</span>
</td>
<td style={{ textAlign: 'center', color: '#c9a84c' }}>{row.visitCount}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Contact Summary */}
<div className="admin-stats-head admin-stats-head--visitors" style={{ marginTop: '2rem' }}>
<h2>Contact Summary</h2>
<p>Submission totals from the contact form.</p>
</div>
<div className="admin-stats-grid">
<article><h3>Total Contact Messages</h3><p>{stats.contactTotals.totalSubmissions.toLocaleString()}</p></article>
<article><h3>Total Bible Questions</h3><p>{stats.contactTotals.totalQuestions.toLocaleString()}</p></article>
</div>
{/* Deployment & Cache Status */}
<div className="admin-stats-head admin-stats-head--visitors" style={{ marginTop: '2rem' }}>
<h2>Deployment &amp; Cache Status</h2>
<p>Current deployment metadata and cache purge health for the live app.</p>
</div>
<div className="admin-stats-grid">
<article><h3>Build Commit</h3><p>{opsStatus?.buildCommit ?? 'Not available'}</p></article>
<article><h3>Build Number</h3><p>{opsStatus?.buildNumber ?? 'Not available'}</p></article>
<article><h3>Deployed At</h3><p>{formatDate(opsStatus?.deployedAt ?? 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>
{/* Data Management */}
<div className="admin-stats-head admin-stats-head--visitors" style={{ marginTop: '2rem' }}>
<h2>Data Management</h2>
<p>Export, backup, or retain only recent analytics data.</p>
</div>
<div className="admin-stats-grid">
<article><h3>Hit Stats Write</h3><p>{stats.writeStatus.hitStats.ok ? 'Healthy' : 'Error'}</p><p>{formatDate(stats.writeStatus.hitStats.at)}</p></article>
<article><h3>Visitor Stats Write</h3><p>{stats.writeStatus.visitorStats.ok ? 'Healthy' : 'Error'}</p><p>{formatDate(stats.writeStatus.visitorStats.at)}</p></article>
<article><h3>Backup Status</h3><p>{stats.writeStatus.backups.ok ? 'Healthy' : 'Error'}</p><p>{formatDate(stats.writeStatus.backups.at)}</p></article>
</div>
<div className="admin-actions admin-actions--maintenance">
<button type="button" className="btn-admin-reset" onClick={onExport}>Export JSON</button>
<button type="button" className="btn-admin-reset" onClick={onBackupNow}>Backup Now</button>
<button type="button" className="btn-admin-reset" onClick={onPrune}>Prune Old Data</button>
<button type="button" className="btn-admin-remove" onClick={onClear}>Clear Analytics</button>
</div>
<div className="admin-actions admin-actions--maintenance">
<button type="button" className="btn-admin-reset" onClick={onPurgeCache}>Purge Cache</button>
<button type="button" className="btn-admin-reset" onClick={onDeployHook}>Trigger Deploy</button>
<button type="button" className="btn-admin-reset" onClick={onRefreshStatus}>Refresh Status</button>
</div>
<div className="admin-restore-row">
<label htmlFor="restore-backup">Restore Backup</label>
<select id="restore-backup" value={selectedBackup} onChange={e => onBackupSelect(e.target.value)} disabled={backupFiles.length === 0}>
{backupFiles.length === 0 && <option value="">No backups found</option>}
{backupFiles.map(file => <option key={file.filename} value={file.filename}>{file.filename}</option>)}
</select>
<button type="button" className="btn-admin-reset" onClick={onRestore} disabled={!selectedBackup}>Restore Selected Backup</button>
</div>
{selectedBackupPreview && (
<div className="admin-restore-preview">
<h3>Restore Preview</h3>
<p><strong>Backup:</strong> {selectedBackupPreview.filename}</p>
<p><strong>Created:</strong> {formatDate(selectedBackupPreview.createdAt)}</p>
<p><strong>Size:</strong> {(selectedBackupPreview.sizeBytes / 1024).toFixed(1)} KB</p>
<p><strong>Total Hits:</strong> {selectedBackupPreview.totalHits.toLocaleString()}</p>
<p><strong>Total Visits:</strong> {selectedBackupPreview.totalVisits.toLocaleString()}</p>
</div>
)}
{maintenanceMsg && <p className="admin-stats-note">{maintenanceMsg}</p>}
</section>
)
}