Files
portfolio/components/AnalyticsDashboard.tsx
denshooter 40d9489395 feat: enhance analytics and performance tracking with real data metrics
- Integrate real page view data from the database for accurate analytics.
- Implement cache-busting for fresh data retrieval in analytics dashboard.
- Calculate and display bounce rate, average session duration, and unique users.
- Refactor performance metrics to ensure only real data is considered.
- Improve user experience with toast notifications for success and error messages.
- Update project editor with undo/redo functionality and enhanced content management.
2026-01-10 03:08:25 +01:00

579 lines
24 KiB
TypeScript

'use client';
import { useState, useEffect, useCallback } from 'react';
import { motion } from 'framer-motion';
import {
BarChart3,
TrendingUp,
Eye,
Heart,
Zap,
Globe,
Activity,
Target,
Award,
RefreshCw,
MousePointer,
RotateCcw,
Trash2,
AlertTriangle
} from 'lucide-react';
import { useToast } from '@/components/Toast';
interface AnalyticsData {
overview: {
totalProjects: number;
publishedProjects: number;
featuredProjects: number;
totalViews: number;
avgLighthouse: number;
};
projects: Array<{
id: number;
title: string;
category: string;
difficulty: string;
views: number;
lighthouse: number;
published: boolean;
featured: boolean;
createdAt: string;
updatedAt: string;
}>;
categories: Record<string, number>;
difficulties: Record<string, number>;
performance: {
avgLighthouse: number;
totalViews: number;
};
metrics: {
bounceRate: number;
avgSessionDuration: number;
pagesPerSession: number;
newUsers: number;
};
}
interface AnalyticsDashboardProps {
isAuthenticated: boolean;
}
export function AnalyticsDashboard({ isAuthenticated }: AnalyticsDashboardProps) {
const [data, setData] = useState<AnalyticsData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [timeRange, setTimeRange] = useState<'7d' | '30d' | '90d' | '1y'>('30d');
const [showResetModal, setShowResetModal] = useState(false);
const [resetType, setResetType] = useState<'analytics' | 'pageviews' | 'interactions' | 'performance' | 'all'>('analytics');
const [resetting, setResetting] = useState(false);
const { showSuccess, showError } = useToast();
const fetchAnalyticsData = useCallback(async () => {
if (!isAuthenticated) return;
try {
setLoading(true);
setError(null);
// Add cache-busting parameter to ensure fresh data after reset
const cacheBust = `?nocache=true&t=${Date.now()}`;
const [analyticsRes, performanceRes] = await Promise.all([
fetch(`/api/analytics/dashboard${cacheBust}`, {
headers: { 'x-admin-request': 'true' }
}),
fetch(`/api/analytics/performance${cacheBust}`, {
headers: { 'x-admin-request': 'true' }
})
]);
if (!analyticsRes.ok || !performanceRes.ok) {
throw new Error('Failed to fetch analytics data');
}
const [analytics, performance] = await Promise.all([
analyticsRes.json(),
performanceRes.json()
]);
setData({
overview: analytics.overview || {
totalProjects: 0,
publishedProjects: 0,
featuredProjects: 0,
totalViews: 0,
avgLighthouse: 90
},
projects: analytics.projects || [],
categories: analytics.categories || {},
difficulties: analytics.difficulties || {},
performance: {
avgLighthouse: performance.avgLighthouse || analytics.overview?.avgLighthouse || 0,
totalViews: performance.totalViews || analytics.overview?.totalViews || 0,
},
metrics: performance.metrics || analytics.metrics || {
bounceRate: 0,
avgSessionDuration: 0,
pagesPerSession: 0,
newUsers: 0
}
});
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load analytics');
} finally {
setLoading(false);
}
}, [isAuthenticated]);
const resetAnalytics = async () => {
if (!isAuthenticated || resetting) return;
setResetting(true);
setError(null);
try {
const response = await fetch('/api/analytics/reset', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-admin-request': 'true'
},
body: JSON.stringify({ type: resetType })
});
const result = await response.json();
if (response.ok) {
showSuccess(
'Analytics Reset',
`Successfully reset ${resetType === 'all' ? 'all analytics data' : resetType} data.`
);
setShowResetModal(false);
// Clear cache and refresh data
await fetchAnalyticsData();
} else {
const errorMsg = result.error || 'Failed to reset analytics';
setError(errorMsg);
showError('Reset Failed', errorMsg);
}
} catch (err) {
const errorMsg = 'Failed to reset analytics. Please try again.';
setError(errorMsg);
showError('Reset Failed', errorMsg);
console.error('Reset error:', err);
} finally {
setResetting(false);
}
};
useEffect(() => {
if (isAuthenticated) {
fetchAnalyticsData();
}
}, [isAuthenticated, fetchAnalyticsData]);
const StatCard = ({ title, value, icon: Icon, color, description, tooltip }: {
title: string;
value: number | string;
icon: React.ComponentType<{ className?: string; size?: number }>;
color: string;
description?: string;
tooltip?: string;
}) => (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white border border-stone-200 p-6 rounded-xl hover:shadow-md transition-all duration-200 group relative"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center space-x-3 mb-4">
<div className={`p-3 rounded-xl ${color}`}>
<Icon className="w-6 h-6" size={24} />
</div>
<div>
<p className="text-stone-500 text-sm font-medium">{title}</p>
{description && <p className="text-stone-400 text-xs">{description}</p>}
</div>
</div>
<p className="text-3xl font-bold text-stone-900 mb-2">{value}</p>
</div>
</div>
{tooltip && (
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-stone-900/95 text-stone-50 text-xs font-medium rounded-lg opacity-0 group-hover:opacity-100 transition-opacity whitespace-normal max-w-xs z-50 shadow-xl backdrop-blur-sm pointer-events-none">
{tooltip}
<div className="absolute top-full left-1/2 -translate-x-1/2 -mt-1 w-2 h-2 bg-stone-900/95 rotate-45"></div>
</div>
)}
</motion.div>
);
const getDifficultyColor = (difficulty: string) => {
switch (difficulty) {
case 'Beginner': return 'bg-stone-50 text-stone-700 border-stone-200';
case 'Intermediate': return 'bg-stone-100 text-stone-700 border-stone-300';
case 'Advanced': return 'bg-stone-200 text-stone-800 border-stone-400';
case 'Expert': return 'bg-stone-300 text-stone-900 border-stone-500';
default: return 'bg-stone-50 text-stone-600 border-stone-200';
}
};
const getCategoryColor = (index: number) => {
const colors = [
'bg-stone-100 text-stone-700',
'bg-stone-200 text-stone-800',
'bg-stone-300 text-stone-900',
'bg-stone-100 text-stone-700',
'bg-stone-200 text-stone-800'
];
return colors[index % colors.length];
};
// Authentication disabled - show analytics directly
return (
<div className="space-y-8">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-bold text-stone-900 flex items-center">
<BarChart3 className="w-8 h-8 mr-3 text-stone-600" />
Analytics Dashboard
</h1>
<p className="text-stone-500 mt-2">Portfolio performance and analytics metrics</p>
</div>
<div className="flex items-center space-x-3">
{/* Time Range Selector */}
<div className="flex items-center space-x-1 bg-white border border-stone-200 rounded-xl p-1">
{(['7d', '30d', '90d', '1y'] as const).map((range) => (
<button
key={range}
onClick={() => setTimeRange(range)}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200 ${
timeRange === range
? 'bg-stone-100 text-stone-900 shadow-sm'
: 'text-stone-500 hover:text-stone-800 hover:bg-stone-50'
}`}
>
{range === '7d' ? '7 Days' : range === '30d' ? '30 Days' : range === '90d' ? '90 Days' : '1 Year'}
</button>
))}
</div>
<button
onClick={fetchAnalyticsData}
disabled={loading}
className="flex items-center space-x-2 px-4 py-2 bg-white border border-stone-200 rounded-xl hover:bg-stone-50 transition-all duration-200 disabled:opacity-50 text-stone-600"
>
<RefreshCw className={`w-4 h-4 text-stone-600 ${loading ? 'animate-spin' : ''}`} />
<span className="font-medium">Refresh</span>
</button>
<button
onClick={() => setShowResetModal(true)}
className="flex items-center space-x-2 px-4 py-2 bg-red-50 text-red-600 border border-red-100 rounded-xl hover:bg-red-100 transition-all"
>
<RotateCcw className="w-4 h-4" />
<span>Reset</span>
</button>
</div>
</div>
{loading && (
<div className="bg-white border border-stone-200 p-8 rounded-xl shadow-sm">
<div className="flex items-center justify-center space-x-3">
<RefreshCw className="w-6 h-6 text-stone-600 animate-spin" />
<span className="text-stone-500 text-lg">Loading analytics data...</span>
</div>
</div>
)}
{error && (
<div className="bg-white border border-red-200 p-6 rounded-xl">
<div className="flex items-center space-x-3 text-red-600">
<Activity className="w-5 h-5" />
<span>Error: {error}</span>
</div>
</div>
)}
{data && !loading && (
<>
{/* Overview Stats */}
<div>
<h2 className="text-xl font-bold text-stone-900 mb-6 flex items-center">
<Target className="w-5 h-5 mr-2 text-stone-600" />
Overview
</h2>
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
<StatCard
title="Total Views"
value={data.overview.totalViews.toLocaleString()}
icon={Eye}
color="bg-stone-100 text-stone-600"
description="All-time page views"
tooltip="✅ REAL DATA: Total page views tracked from the PageView database table. Each visit to a project page or the homepage is automatically recorded with IP, user agent, and timestamp."
/>
<StatCard
title="Projects"
value={data.overview.totalProjects}
icon={Globe}
color="bg-stone-100 text-stone-600"
description={`${data.overview.publishedProjects} published`}
tooltip="✅ REAL DATA: Total number of projects in your portfolio. Shows published vs unpublished projects from your database."
/>
<StatCard
title="Performance"
value={data.overview.avgLighthouse > 0 ? data.overview.avgLighthouse : 'N/A'}
icon={Zap}
color="bg-stone-100 text-stone-600"
description={data.overview.avgLighthouse > 0 ? "Avg Lighthouse score" : "No performance data yet"}
tooltip={data.overview.avgLighthouse > 0
? "✅ REAL DATA: Average Lighthouse performance score (0-100) calculated from real Web Vitals metrics (LCP, FCP, CLS, FID, TTFB) collected from actual page visits. Only shown when real performance data exists."
: "No performance data collected yet. Scores will appear after visitors load your pages and Web Vitals are tracked."}
/>
<StatCard
title="Bounce Rate"
value={`${data.metrics?.bounceRate || 0}%`}
icon={MousePointer}
color="bg-stone-100 text-stone-600"
description="User retention"
tooltip="✅ REAL DATA: Percentage of sessions where users viewed only one page before leaving. Calculated from PageView records grouped by IP address. Lower is better."
/>
<StatCard
title="Avg Session"
value={data.metrics?.avgSessionDuration ? `${Math.round(data.metrics.avgSessionDuration / 60)}m` : '0m'}
icon={Activity}
color="bg-stone-100 text-stone-600"
description="Average session duration"
tooltip="✅ REAL DATA: Average time users spend on your site per session, calculated from the time difference between first and last pageview per IP address. Only calculated for sessions with multiple pageviews."
/>
</div>
</div>
{/* Project Performance */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Top Projects */}
<div className="bg-white border border-stone-200 p-6 rounded-xl shadow-sm">
<h3 className="text-xl font-bold text-stone-900 mb-6 flex items-center">
<Award className="w-5 h-5 mr-2 text-stone-600" />
Top Performing Projects
</h3>
<div className="space-y-4">
{data.projects
.sort((a, b) => b.views - a.views)
.slice(0, 5)
.map((project, index) => (
<motion.div
key={project.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.1 }}
className="flex items-center justify-between p-4 bg-stone-50 rounded-xl border border-stone-100"
>
<div className="flex items-center space-x-4">
<div className="w-8 h-8 bg-stone-600 rounded-lg flex items-center justify-center text-white font-bold shadow-sm">
#{index + 1}
</div>
<div>
<p className="text-stone-900 font-medium">{project.title}</p>
<p className="text-stone-500 text-sm">{project.category}</p>
</div>
</div>
<div className="text-right group/views relative">
<p className="text-stone-900 font-bold">{project.views.toLocaleString()}</p>
<p className="text-stone-500 text-sm">views</p>
<div className="absolute bottom-full right-0 mb-2 px-3 py-2 bg-stone-900/95 text-stone-50 text-xs font-medium rounded-lg opacity-0 group-hover/views:opacity-100 transition-opacity whitespace-normal max-w-xs z-50 shadow-xl backdrop-blur-sm pointer-events-none">
REAL DATA: Page views tracked from PageView table for this project. Each visit is automatically recorded.
<div className="absolute top-full right-4 -mt-1 w-2 h-2 bg-stone-900/95 rotate-45"></div>
</div>
</div>
</motion.div>
))}
</div>
</div>
{/* Categories Distribution */}
<div className="bg-white border border-stone-200 p-6 rounded-xl shadow-sm">
<h3 className="text-xl font-bold text-stone-900 mb-6 flex items-center">
<BarChart3 className="w-5 h-5 mr-2 text-stone-600" />
Categories
</h3>
<div className="space-y-4">
{Object.entries(data.categories).map(([category, count], index) => (
<motion.div
key={category}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.1 }}
className="flex items-center justify-between"
>
<div className="flex items-center space-x-3">
<div className={`w-4 h-4 rounded-full ${getCategoryColor(index)}`}></div>
<span className="text-stone-700 font-medium">{category}</span>
</div>
<div className="flex items-center space-x-3">
<div className="w-32 h-2 bg-stone-100 rounded-full overflow-hidden">
<div
className={`h-full ${getCategoryColor(index)} transition-all duration-500`}
style={{ width: `${(count / Math.max(...Object.values(data.categories))) * 100}%` }}
></div>
</div>
<span className="text-stone-500 font-medium w-8 text-right">{count}</span>
</div>
</motion.div>
))}
</div>
</div>
</div>
{/* Difficulty & Activity */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Difficulty Distribution */}
<div className="bg-white border border-stone-200 p-6 rounded-xl shadow-sm">
<h3 className="text-xl font-bold text-stone-900 mb-6 flex items-center">
<Target className="w-5 h-5 mr-2 text-stone-600" />
Difficulty Levels
</h3>
<div className="grid grid-cols-2 gap-4">
{Object.entries(data.difficulties).map(([difficulty, count]) => (
<motion.div
key={difficulty}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className={`p-4 rounded-xl border ${getDifficultyColor(difficulty)}`}
>
<div className="text-center">
<p className="text-2xl font-bold mb-1">{count}</p>
<p className="text-sm font-medium">{difficulty}</p>
</div>
</motion.div>
))}
</div>
</div>
{/* Recent Activity */}
<div className="bg-white border border-stone-200 p-6 rounded-xl shadow-sm">
<h3 className="text-xl font-bold text-stone-900 mb-6 flex items-center">
<Activity className="w-5 h-5 mr-2 text-blue-600" />
Recent Activity
</h3>
<div className="space-y-4">
{data.projects
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
.slice(0, 4)
.map((project, index) => (
<motion.div
key={project.id}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
className="flex items-center space-x-4 p-3 bg-stone-50 rounded-xl border border-stone-100"
>
<div className="w-2 h-2 bg-stone-500 rounded-full animate-pulse"></div>
<div className="flex-1">
<p className="text-stone-900 font-medium text-sm">{project.title}</p>
<p className="text-stone-500 text-xs">
Updated {new Date(project.updatedAt).toLocaleDateString()}
</p>
</div>
<div className="flex items-center space-x-2">
{project.featured && (
<span className="px-2 py-1 bg-stone-100 text-stone-700 rounded-full text-xs font-medium">
Featured
</span>
)}
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
project.published
? 'bg-stone-100 text-stone-700'
: 'bg-stone-200 text-stone-700'
}`}>
{project.published ? 'Live' : 'Draft'}
</span>
</div>
</motion.div>
))}
</div>
</div>
</div>
</>
)}
{/* Reset Modal */}
{showResetModal && (
<div className="fixed inset-0 bg-stone-900/20 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="bg-white border border-stone-200 rounded-2xl p-6 w-full max-w-md shadow-xl"
>
<div className="flex items-center space-x-3 mb-4">
<div className="w-10 h-10 bg-red-100 rounded-lg flex items-center justify-center">
<AlertTriangle className="w-5 h-5 text-red-600" />
</div>
<div>
<h3 className="text-lg font-bold text-stone-900">Reset Analytics Data</h3>
<p className="text-stone-500 text-sm">This action cannot be undone</p>
</div>
</div>
<div className="space-y-4 mb-6">
<div>
<label className="block text-stone-600 text-sm mb-2">Reset Type</label>
<select
value={resetType}
onChange={(e) => setResetType(e.target.value as 'analytics' | 'pageviews' | 'interactions' | 'performance' | 'all')}
className="w-full px-3 py-2 bg-stone-50 border border-stone-200 rounded-lg text-stone-900 focus:outline-none focus:ring-2 focus:ring-red-500"
>
<option value="analytics">Analytics Only (project view counts)</option>
<option value="pageviews">Page Views Only (all tracked visits)</option>
<option value="interactions">User Interactions Only</option>
<option value="performance">Performance Metrics Only (Lighthouse scores)</option>
<option value="all">Everything (Complete Reset)</option>
</select>
</div>
<div className="bg-red-50 border border-red-100 rounded-lg p-3">
<div className="flex items-start space-x-2">
<AlertTriangle className="w-4 h-4 text-red-500 mt-0.5 flex-shrink-0" />
<div className="text-sm text-red-700">
<p className="font-medium mb-1">Warning:</p>
<p>This will permanently delete the selected analytics data. This action cannot be reversed.</p>
</div>
</div>
</div>
</div>
<div className="flex items-center space-x-3">
<button
onClick={() => setShowResetModal(false)}
disabled={resetting}
className="flex-1 px-4 py-2 bg-white border border-stone-200 text-stone-700 rounded-lg hover:bg-stone-50 transition-all disabled:opacity-50"
>
Cancel
</button>
<button
onClick={resetAnalytics}
disabled={resetting}
className="flex-1 flex items-center justify-center space-x-2 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-all disabled:opacity-50"
>
{resetting ? (
<>
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
<span>Resetting...</span>
</>
) : (
<>
<Trash2 className="w-4 h-4" />
<span>Reset {resetType === 'all' ? 'Everything' : 'Data'}</span>
</>
)}
</button>
</div>
</motion.div>
</div>
)}
</div>
);
}
export default AnalyticsDashboard;