From bb9a78f7552c68c826764de9155325f708dba419 Mon Sep 17 00:00:00 2001 From: robin Date: Mon, 19 Jan 2026 12:06:47 +0100 Subject: [PATCH] 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 --- src/app/stats/page.tsx | 33 ++- src/components/AnalyticsDashboard.tsx | 406 ++++++++++++++++++++++++++ 2 files changed, 432 insertions(+), 7 deletions(-) create mode 100644 src/components/AnalyticsDashboard.tsx diff --git a/src/app/stats/page.tsx b/src/app/stats/page.tsx index 7506c11..44807ce 100644 --- a/src/app/stats/page.tsx +++ b/src/app/stats/page.tsx @@ -1,8 +1,8 @@ 'use client'; import { useRouter } from 'next/navigation'; -import { ArrowLeft, BarChart3 } from 'lucide-react'; -import StatsDashboard from '@/components/StatsDashboard'; +import { ArrowLeft } from 'lucide-react'; +import AnalyticsDashboard from '@/components/AnalyticsDashboard'; import { useI18n } from '@/i18n/I18nContext'; import { useEffect, useState } from 'react'; import { createClient } from '@/lib/supabase/client'; @@ -11,21 +11,34 @@ export default function StatsPage() { const router = useRouter(); const { t } = useI18n(); const [bottles, setBottles] = useState([]); + const [isLoading, setIsLoading] = useState(true); const supabase = createClient(); useEffect(() => { const fetchBottles = async () => { + setIsLoading(true); const { data } = await supabase .from('bottles') - .select('*'); + .select(` + id, + name, + distillery, + purchase_price, + category, + abv, + age, + status, + tastings (rating) + `); setBottles(data || []); + setIsLoading(false); }; fetchBottles(); }, []); return (
-
+
{/* Header */}
- {/* Stats Dashboard */} - + {/* Dashboard */} + {isLoading ? ( +
+
+
+ ) : ( + + )}
); diff --git a/src/components/AnalyticsDashboard.tsx b/src/components/AnalyticsDashboard.tsx new file mode 100644 index 0000000..bd0f417 --- /dev/null +++ b/src/components/AnalyticsDashboard.tsx @@ -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(); + 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(); + 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(); + 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 ( +
+

{label}

+ {payload.map((entry: any, index: number) => ( +

+ {entry.name}: {entry.value} +

+ ))} +
+ ); + } + return null; + }; + + // Scatter Tooltip + const ScatterTooltip = ({ active, payload }: any) => { + if (active && payload && payload.length) { + const data = payload[0].payload; + return ( +
+

{data.name}

+

Price: {data.x}€

+

Rating: {data.y?.toFixed(1)}

+
+ ); + } + return null; + }; + + return ( +
+ + {/* KPI Cards */} +
+
+
+ + Total Value +
+
+ {stats.totalValue.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })} +
+
+ +
+
+ + Bottles +
+
+ {stats.totalCount} +
+
+ +
+
+ + Avg Rating +
+
+ {stats.avgCollectionRating.toFixed(1)} +
+
+ +
+
+ + Favorite +
+
+ {stats.topDistillery} +
+
+
+ + {/* Row 1: Categories & Status */} +
+ + {/* Category Distribution */} +
+

+ + Categories +

+
+ + + + {stats.categoryData.map((entry, index) => ( + + ))} + + } /> + + + +
+
+ + {/* Status Distribution */} +
+

+ + Status +

+
+ + + + + + } /> + + {stats.statusData.map((entry, index) => ( + + ))} + + + +
+
+
+ + {/* Row 2: Distillery Top 10 */} +
+

+ + Top Distilleries +

+
+ + + + + + } cursor={{ fill: '#27272a' }} /> + + + +
+
+ + {/* Row 3: Technical Specs (ABV & Age) */} +
+ + {/* ABV */} +
+

+ + Strength (ABV) +

+
+ + + + + + + + + + + + } /> + + + +
+
+ + {/* Age */} +
+

+ + Age Statements +

+
+ + + + + + } cursor={{ fill: '#27272a' }} /> + + + +
+
+
+ + {/* Row 4: Price vs Quality */} +
+
+

+ + Price vs. Quality +

+ Excludes free/unrated bottles +
+

+ Find hidden gems: Low price (left) but high rating (top). +

+
+ + + + + + + } cursor={{ strokeDasharray: '3 3' }} /> + + + +
+
+ +
+ ); +}