feat: Revamp Analytics Dashboard

- Replace StatsDashboard with new AnalyticsDashboard component
- Add Recharts charts: Category Pie, ABV Area, Age Bar, Top Distilleries Bar, Price vs Quality Scatter
- Update fetching logic to include tasting ratings for analysis
- Enhance UI with new KPI cards and dark mode styling
This commit is contained in:
2026-01-19 12:06:47 +01:00
parent 45f562e2ce
commit bb9a78f755
2 changed files with 432 additions and 7 deletions

View File

@@ -1,8 +1,8 @@
'use client'; 'use client';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { ArrowLeft, BarChart3 } from 'lucide-react'; import { ArrowLeft } from 'lucide-react';
import StatsDashboard from '@/components/StatsDashboard'; import AnalyticsDashboard from '@/components/AnalyticsDashboard';
import { useI18n } from '@/i18n/I18nContext'; import { useI18n } from '@/i18n/I18nContext';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { createClient } from '@/lib/supabase/client'; import { createClient } from '@/lib/supabase/client';
@@ -11,21 +11,34 @@ export default function StatsPage() {
const router = useRouter(); const router = useRouter();
const { t } = useI18n(); const { t } = useI18n();
const [bottles, setBottles] = useState<any[]>([]); const [bottles, setBottles] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
const supabase = createClient(); const supabase = createClient();
useEffect(() => { useEffect(() => {
const fetchBottles = async () => { const fetchBottles = async () => {
setIsLoading(true);
const { data } = await supabase const { data } = await supabase
.from('bottles') .from('bottles')
.select('*'); .select(`
id,
name,
distillery,
purchase_price,
category,
abv,
age,
status,
tastings (rating)
`);
setBottles(data || []); setBottles(data || []);
setIsLoading(false);
}; };
fetchBottles(); fetchBottles();
}, []); }, []);
return ( return (
<main className="min-h-screen bg-zinc-950 p-4 pb-24"> <main className="min-h-screen bg-zinc-950 p-4 pb-24">
<div className="max-w-4xl mx-auto"> <div className="max-w-6xl mx-auto">
{/* Header */} {/* Header */}
<div className="flex items-center gap-4 mb-8"> <div className="flex items-center gap-4 mb-8">
<button <button
@@ -39,13 +52,19 @@ export default function StatsPage() {
{t('home.stats.title')} {t('home.stats.title')}
</h1> </h1>
<p className="text-sm text-zinc-500"> <p className="text-sm text-zinc-500">
Your collection analytics Deep dive into your collection
</p> </p>
</div> </div>
</div> </div>
{/* Stats Dashboard */} {/* Dashboard */}
<StatsDashboard bottles={bottles} /> {isLoading ? (
<div className="flex items-center justify-center py-20">
<div className="w-8 h-8 border-2 border-orange-500 border-t-transparent rounded-full animate-spin"></div>
</div>
) : (
<AnalyticsDashboard bottles={bottles} />
)}
</div> </div>
</main> </main>
); );

View File

@@ -0,0 +1,406 @@
'use client';
import React, { useMemo } from 'react';
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
PieChart, Pie, Cell, ScatterChart, Scatter, ZAxis, Legend, AreaChart, Area
} from 'recharts';
import { TrendingUp, CreditCard, Star, Home, BarChart3, Droplets, Clock, Activity, DollarSign } from 'lucide-react';
import { useI18n } from '@/i18n/I18nContext';
interface Bottle {
id: string;
name: string;
distillery?: string;
purchase_price?: number | null;
rating?: number | null; // Calculated avg rating
category?: string;
abv?: number;
age?: number;
status?: string | null;
tastings?: { rating: number }[];
}
interface AnalyticsDashboardProps {
bottles: Bottle[];
}
// Custom Colors
const COLORS = [
'#f97316', // Orange 500
'#a855f7', // Purple 500
'#3b82f6', // Blue 500
'#10b981', // Emerald 500
'#ef4444', // Red 500
'#eab308', // Yellow 500
'#ec4899', // Pink 500
'#6366f1', // Indigo 500
];
export default function AnalyticsDashboard({ bottles }: AnalyticsDashboardProps) {
const { t, locale } = useI18n();
// --- Process Data ---
const stats = useMemo(() => {
const enrichedBottles = bottles.map(b => {
const ratings = b.tastings?.map(t => t.rating) || [];
const avgRating = ratings.length > 0
? ratings.reduce((a, b) => a + b, 0) / ratings.length
: null;
return { ...b, rating: avgRating };
});
// 1. High Level Metrics
const totalValue = enrichedBottles.reduce((sum, b) => sum + (Number(b.purchase_price) || 0), 0);
const bottlesWithRating = enrichedBottles.filter(b => b.rating !== null);
const avgCollectionRating = bottlesWithRating.length > 0
? bottlesWithRating.reduce((sum, b) => sum + (b.rating || 0), 0) / bottlesWithRating.length
: 0;
// 2. Category Distribution
const catMap = new Map<string, number>();
enrichedBottles.forEach(b => {
const cat = b.category || 'Unknown';
catMap.set(cat, (catMap.get(cat) || 0) + 1);
});
const categoryData = Array.from(catMap.entries())
.map(([name, value]) => ({ name, value }))
.sort((a, b) => b.value - a.value);
// 3. Status Distribution
const statusMap = new Map<string, number>();
enrichedBottles.forEach(b => {
const s = b.status || 'sealed';
statusMap.set(s, (statusMap.get(s) || 0) + 1);
});
const statusData = Array.from(statusMap.entries())
.map(([name, value]) => ({ name: name.charAt(0).toUpperCase() + name.slice(1), value }));
// 4. Distillery Top 10
const distMap = new Map<string, number>();
enrichedBottles.forEach(b => {
if (b.distillery) {
distMap.set(b.distillery, (distMap.get(b.distillery) || 0) + 1);
}
});
const distilleryData = Array.from(distMap.entries())
.map(([name, value]) => ({ name, value }))
.sort((a, b) => b.value - a.value)
.slice(0, 10);
// 5. ABV Buckets
const abvBuckets = {
'< 40%': 0,
'40-43%': 0,
'43-46%': 0,
'46-50%': 0,
'50-55%': 0,
'55-60%': 0,
'> 60%': 0,
};
enrichedBottles.forEach(b => {
if (!b.abv) return;
if (b.abv < 40) abvBuckets['< 40%']++;
else if (b.abv <= 43) abvBuckets['40-43%']++;
else if (b.abv <= 46) abvBuckets['43-46%']++;
else if (b.abv <= 50) abvBuckets['46-50%']++;
else if (b.abv <= 55) abvBuckets['50-55%']++;
else if (b.abv <= 60) abvBuckets['55-60%']++;
else abvBuckets['> 60%']++;
});
const abvData = Object.entries(abvBuckets).map(([name, value]) => ({ name, value }));
// 6. Age Buckets
const ageBuckets = {
'NAS': 0,
'< 10y': 0,
'10-12y': 0,
'13-18y': 0,
'19-25y': 0,
'> 25y': 0
};
enrichedBottles.forEach(b => {
if (!b.age) {
ageBuckets['NAS']++;
return;
}
if (b.age < 10) ageBuckets['< 10y']++;
else if (b.age <= 12) ageBuckets['10-12y']++;
else if (b.age <= 18) ageBuckets['13-18y']++;
else if (b.age <= 25) ageBuckets['19-25y']++;
else ageBuckets['> 25y']++;
});
const ageData = Object.entries(ageBuckets).map(([name, value]) => ({ name, value }));
// 7. Price vs Quality
const scatterData = enrichedBottles
.filter(b => b.purchase_price && b.rating)
.map(b => ({
x: b.purchase_price,
y: b.rating,
name: b.name,
z: 1 // size
}));
return {
totalValue,
avgCollectionRating,
totalCount: bottles.length,
topDistillery: distilleryData[0]?.name || 'N/A',
categoryData,
statusData,
distilleryData,
abvData,
ageData,
scatterData
};
}, [bottles]);
// Helper for Custom Tooltip
const CustomTooltip = ({ active, payload, label }: any) => {
if (active && payload && payload.length) {
return (
<div className="bg-zinc-900 border border-zinc-800 p-3 rounded-xl shadow-xl">
<p className="font-bold text-white mb-1">{label}</p>
{payload.map((entry: any, index: number) => (
<p key={index} className="text-sm" style={{ color: entry.color }}>
{entry.name}: {entry.value}
</p>
))}
</div>
);
}
return null;
};
// Scatter Tooltip
const ScatterTooltip = ({ active, payload }: any) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (
<div className="bg-zinc-900 border border-zinc-800 p-3 rounded-xl shadow-xl max-w-[200px]">
<p className="font-bold text-white mb-1 text-xs">{data.name}</p>
<p className="text-xs text-zinc-400">Price: <span className="text-green-500 font-bold">{data.x}</span></p>
<p className="text-xs text-zinc-400">Rating: <span className="text-orange-500 font-bold">{data.y?.toFixed(1)}</span></p>
</div>
);
}
return null;
};
return (
<div className="space-y-6 animate-in fade-in slide-in-from-top-4 duration-500">
{/* KPI Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-zinc-900 border border-zinc-800 p-4 rounded-2xl">
<div className="flex items-center gap-2 mb-2 text-zinc-500">
<CreditCard size={18} className="text-green-500" />
<span className="text-xs font-bold uppercase tracking-wider">Total Value</span>
</div>
<div className="text-2xl md:text-3xl font-black text-white">
{stats.totalValue.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })}
</div>
</div>
<div className="bg-zinc-900 border border-zinc-800 p-4 rounded-2xl">
<div className="flex items-center gap-2 mb-2 text-zinc-500">
<Home size={18} className="text-blue-500" />
<span className="text-xs font-bold uppercase tracking-wider">Bottles</span>
</div>
<div className="text-2xl md:text-3xl font-black text-white">
{stats.totalCount}
</div>
</div>
<div className="bg-zinc-900 border border-zinc-800 p-4 rounded-2xl">
<div className="flex items-center gap-2 mb-2 text-zinc-500">
<Star size={18} className="text-orange-500" />
<span className="text-xs font-bold uppercase tracking-wider">Avg Rating</span>
</div>
<div className="text-2xl md:text-3xl font-black text-white">
{stats.avgCollectionRating.toFixed(1)}
</div>
</div>
<div className="bg-zinc-900 border border-zinc-800 p-4 rounded-2xl">
<div className="flex items-center gap-2 mb-2 text-zinc-500">
<Activity size={18} className="text-purple-500" />
<span className="text-xs font-bold uppercase tracking-wider">Favorite</span>
</div>
<div className="text-xl md:text-2xl font-black text-white truncate" title={stats.topDistillery}>
{stats.topDistillery}
</div>
</div>
</div>
{/* Row 1: Categories & Status */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Category Distribution */}
<div className="bg-zinc-900 border border-zinc-800 p-6 rounded-2xl">
<h3 className="font-bold text-white mb-6 flex items-center gap-2">
<BarChart3 size={20} className="text-zinc-500" />
Categories
</h3>
<div className="h-[300px] w-full">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={stats.categoryData}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={100}
paddingAngle={5}
dataKey="value"
stroke="none"
>
{stats.categoryData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip content={<CustomTooltip />} />
<Legend wrapperStyle={{ fontSize: '12px', paddingTop: '20px' }} />
</PieChart>
</ResponsiveContainer>
</div>
</div>
{/* Status Distribution */}
<div className="bg-zinc-900 border border-zinc-800 p-6 rounded-2xl">
<h3 className="font-bold text-white mb-6 flex items-center gap-2">
<Activity size={20} className="text-zinc-500" />
Status
</h3>
<div className="h-[300px] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={stats.statusData} layout="vertical" margin={{ left: 20 }}>
<CartesianGrid strokeDasharray="3 3" horizontal={false} stroke="#27272a" />
<XAxis type="number" stroke="#52525b" fontSize={12} />
<YAxis dataKey="name" type="category" stroke="#a1a1aa" fontSize={12} width={80} />
<Tooltip content={<CustomTooltip />} />
<Bar dataKey="value" name="Bottles" radius={[0, 4, 4, 0]}>
{stats.statusData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
{/* Row 2: Distillery Top 10 */}
<div className="bg-zinc-900 border border-zinc-800 p-6 rounded-2xl">
<h3 className="font-bold text-white mb-6 flex items-center gap-2">
<Home size={20} className="text-zinc-500" />
Top Distilleries
</h3>
<div className="h-[300px] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={stats.distilleryData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#27272a" />
<XAxis dataKey="name" stroke="#a1a1aa" fontSize={12} interval={0} angle={-45} textAnchor="end" height={60} />
<YAxis stroke="#52525b" fontSize={12} />
<Tooltip content={<CustomTooltip />} cursor={{ fill: '#27272a' }} />
<Bar dataKey="value" name="Bottles" fill="#f97316" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
{/* Row 3: Technical Specs (ABV & Age) */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* ABV */}
<div className="bg-zinc-900 border border-zinc-800 p-6 rounded-2xl">
<h3 className="font-bold text-white mb-6 flex items-center gap-2">
<Droplets size={20} className="text-zinc-500" />
Strength (ABV)
</h3>
<div className="h-[250px] w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={stats.abvData}>
<defs>
<linearGradient id="colorAbv" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#8b5cf6" stopOpacity={0.3} />
<stop offset="95%" stopColor="#8b5cf6" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#27272a" />
<XAxis dataKey="name" stroke="#a1a1aa" fontSize={10} />
<YAxis stroke="#52525b" fontSize={10} />
<Tooltip content={<CustomTooltip />} />
<Area type="monotone" dataKey="value" name="Bottles" stroke="#8b5cf6" fillOpacity={1} fill="url(#colorAbv)" />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
{/* Age */}
<div className="bg-zinc-900 border border-zinc-800 p-6 rounded-2xl">
<h3 className="font-bold text-white mb-6 flex items-center gap-2">
<Clock size={20} className="text-zinc-500" />
Age Statements
</h3>
<div className="h-[250px] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={stats.ageData}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#27272a" />
<XAxis dataKey="name" stroke="#a1a1aa" fontSize={10} />
<YAxis stroke="#52525b" fontSize={10} />
<Tooltip content={<CustomTooltip />} cursor={{ fill: '#27272a' }} />
<Bar dataKey="value" name="Bottles" fill="#10b981" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
{/* Row 4: Price vs Quality */}
<div className="bg-zinc-900 border border-zinc-800 p-6 rounded-2xl">
<div className="flex items-center justify-between mb-2">
<h3 className="font-bold text-white flex items-center gap-2">
<DollarSign size={20} className="text-zinc-500" />
Price vs. Quality
</h3>
<span className="text-xs text-zinc-500 px-2 py-1 bg-zinc-800 rounded-lg">Excludes free/unrated bottles</span>
</div>
<p className="text-xs text-zinc-500 mb-6">
Find hidden gems: Low price (left) but high rating (top).
</p>
<div className="h-[400px] w-full">
<ResponsiveContainer width="100%" height="100%">
<ScatterChart margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#27272a" />
<XAxis
type="number"
dataKey="x"
name="Price"
unit="€"
stroke="#52525b"
fontSize={12}
label={{ value: 'Price (€)', position: 'insideBottom', offset: -10, fill: '#71717a', fontSize: 12 }}
/>
<YAxis
type="number"
dataKey="y"
name="Rating"
domain={[0, 100]}
stroke="#52525b"
fontSize={12}
label={{ value: 'Rating (0-100)', angle: -90, position: 'insideLeft', fill: '#71717a', fontSize: 12 }}
/>
<ZAxis type="number" dataKey="z" range={[50, 400]} />
<Tooltip content={<ScatterTooltip />} cursor={{ strokeDasharray: '3 3' }} />
<Scatter name="Bottles" data={stats.scatterData} fill="#ec4899" fillOpacity={0.6} stroke="#fff" strokeWidth={1} />
</ScatterChart>
</ResponsiveContainer>
</div>
</div>
</div>
);
}