Improve analytics UX with geo maps and readability updates

This commit is contained in:
nmemmert
2026-05-07 10:29:49 -04:00
parent 3cbb7cc409
commit 90e5c677d8
9 changed files with 540 additions and 82 deletions
+104 -40
View File
@@ -1,7 +1,32 @@
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'
@@ -49,13 +74,25 @@ export function AnalyticsPanel({
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: stats.last7DaysReal.map((d: { day: string; hits: number }) => d.hits),
data: last7DaysReal.map((d: { day: string; hits: number }) => d.hits),
borderColor: '#c9a84c',
backgroundColor: 'rgba(201, 168, 76, 0.1)',
tension: 0.4,
@@ -63,7 +100,7 @@ export function AnalyticsPanel({
},
{
label: 'Bot Traffic',
data: stats.last7DaysBot.map((d: { day: string; hits: number }) => d.hits),
data: last7DaysBot.map((d: { day: string; hits: number }) => d.hits),
borderColor: '#666',
backgroundColor: 'rgba(102, 102, 102, 0.1)',
tension: 0.4,
@@ -74,9 +111,9 @@ export function AnalyticsPanel({
// Chart data - Bot breakdown (pie)
const botPieData = {
labels: stats.botReasons.map((b: { reason: string; count: number }) => b.reason.replace(/-/g, ' ')),
labels: botReasons.map((b: { reason: string; count: number }) => b.reason.replace(/-/g, ' ')),
datasets: [{
data: stats.botReasons.map((b: { reason: string; count: number }) => b.count),
data: botReasons.map((b: { reason: string; count: number }) => b.count),
backgroundColor: [
'#c9a84c',
'#a0853d',
@@ -98,7 +135,7 @@ export function AnalyticsPanel({
const trafficRatioData = {
labels: ['Real Visitors', 'Bot Traffic'],
datasets: [{
data: [stats.realHits, stats.botHits],
data: [realHits, botHits],
backgroundColor: ['#c9a84c', '#999'],
borderColor: '#1a1a15',
borderWidth: 2,
@@ -106,9 +143,9 @@ export function AnalyticsPanel({
}
// Top paths comparison
const topPathsLabels = Array.from({ length: Math.max(stats.topPathsReal.length, stats.topPathsBot.length) }, (_, i: number) => {
const realPath = stats.topPathsReal[i]?.path || ''
const botPath = stats.topPathsBot[i]?.path || ''
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)
@@ -117,12 +154,12 @@ export function AnalyticsPanel({
datasets: [
{
label: 'Real Hits',
data: topPathsLabels.map((_label, i) => stats.topPathsReal[i]?.hits || 0),
data: topPathsLabels.map((_label, i) => topPathsReal[i]?.hits || 0),
backgroundColor: '#c9a84c',
},
{
label: 'Bot Hits',
data: topPathsLabels.map((_label, i) => stats.topPathsBot[i]?.hits || 0),
data: topPathsLabels.map((_label, i) => topPathsBot[i]?.hits || 0),
backgroundColor: '#999',
},
],
@@ -175,18 +212,18 @@ export function AnalyticsPanel({
</article>
<article>
<h3>Real Traffic</h3>
<p>{stats.realHits.toLocaleString()}</p>
<small>{((stats.realHits / stats.totalHits) * 100).toFixed(1)}% of total</small>
<p>{realHits.toLocaleString()}</p>
<small>{totalHits > 0 ? ((realHits / totalHits) * 100).toFixed(1) : '0.0'}% of total</small>
</article>
<article>
<h3>Bot Traffic</h3>
<p>{stats.botHits.toLocaleString()}</p>
<small>{((stats.botHits / stats.totalHits) * 100).toFixed(1)}% of total</small>
<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>{stats.last30DaysRealTotal.toLocaleString()}</p>
<small>Real hits {stats.last30DaysBotTotal.toLocaleString()} bots</small>
<p>{last30DaysRealTotal.toLocaleString()}</p>
<small>Real hits {last30DaysBotTotal.toLocaleString()} bots</small>
</article>
<article>
<h3>Unique Visitors</h3>
@@ -226,7 +263,7 @@ export function AnalyticsPanel({
<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>
{stats.botReasons.length === 0 ? (
{botReasons.length === 0 ? (
<p className="admin-stats-note">No bot traffic detected.</p>
) : (
<Pie data={botPieData} options={{...chartOptions, plugins: {...chartOptions.plugins}}} />
@@ -234,11 +271,11 @@ export function AnalyticsPanel({
</div>
<div style={{ backgroundColor: '#1a1a15', padding: '1.5rem', borderRadius: '0.5rem', border: '1px solid #2a2518' }}>
<h3 style={{ marginBottom: '1rem' }}>Bot Detection Details</h3>
{stats.botReasons.length === 0 ? (
{botReasons.length === 0 ? (
<p className="admin-stats-note">No bots detected yet.</p>
) : (
<ul style={{ listStyle: 'none', padding: 0 }}>
{stats.botReasons.map((reason: { reason: string; count: number }) => (
{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>
@@ -252,26 +289,15 @@ export function AnalyticsPanel({
{/* Geographic Charts */}
{chartTab === 'geographic' && (
<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' }}>
<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>
<div style={{ backgroundColor: '#1a1a15', padding: '1.5rem', borderRadius: '0.5rem', border: '1px solid #2a2518' }}>
<h3 style={{ marginBottom: '1rem' }}>Top Countries</h3>
{stats.visitors.topCountries.length === 0 ? (
<p className="admin-stats-note">No visitor data yet.</p>
) : (
<ul style={{ listStyle: 'none', padding: 0 }}>
{stats.visitors.topCountries.map((country: { name: string; hits: number }) => (
<li key={country.name} style={{ padding: '0.5rem 0', display: 'flex', justifyContent: 'space-between', borderBottom: '1px solid #2a2518' }}>
<span>{country.name}</span>
<strong style={{ color: '#c9a84c' }}>{country.hits.toLocaleString()}</strong>
</li>
))}
</ul>
)}
</div>
<GeoMaps
topCountries={stats.visitors.topCountries}
topStates={stats.visitors.topStates}
/>
</div>
)}
@@ -290,8 +316,22 @@ export function AnalyticsPanel({
</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>
<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">
@@ -302,12 +342,36 @@ export function AnalyticsPanel({
<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>
<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>{maskIp(row.ip)}</td><td>{row.country}</td><td>{row.path}</td><td>{row.returningVisitor ? 'Yes' : 'No'}</td><td>{row.visitCount}</td>
<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>