From 35c24434734ff57ec6f418d9c121c08830e6154e Mon Sep 17 00:00:00 2001 From: robin Date: Thu, 18 Dec 2025 12:34:51 +0100 Subject: [PATCH] feat: implement QOL features (Stats, Search, Dram of the Day) --- src/app/page.tsx | 24 +++++-- src/components/BottleGrid.tsx | 15 +++- src/components/DramOfTheDay.tsx | 101 +++++++++++++++++++++++++++ src/components/StatsDashboard.tsx | 99 ++++++++++++++++++++++++++ src/services/update-bottle-status.ts | 6 +- supa_schema.sql | 1 + 6 files changed, 235 insertions(+), 11 deletions(-) create mode 100644 src/components/DramOfTheDay.tsx create mode 100644 src/components/StatsDashboard.tsx diff --git a/src/app/page.tsx b/src/app/page.tsx index 60f97d6..ea96301 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -7,6 +7,8 @@ import BottleGrid from "@/components/BottleGrid"; import AuthForm from "@/components/AuthForm"; import BuddyList from "@/components/BuddyList"; import SessionList from "@/components/SessionList"; +import StatsDashboard from "@/components/StatsDashboard"; +import DramOfTheDay from "@/components/DramOfTheDay"; export default function Home() { const supabase = createClientComponentClient(); @@ -61,7 +63,8 @@ export default function Home() { .select(` *, tastings ( - created_at + created_at, + rating ) `) .order('created_at', { ascending: false }); @@ -122,14 +125,21 @@ export default function Home() {

WHISKYVAULT

- +
+ + +
+
+ +
+
diff --git a/src/components/BottleGrid.tsx b/src/components/BottleGrid.tsx index d12f576..0d1f35d 100644 --- a/src/components/BottleGrid.tsx +++ b/src/components/BottleGrid.tsx @@ -148,10 +148,19 @@ export default function BottleGrid({ bottles }: BottleGridProps) { }, [bottles]); const filteredBottles = useMemo(() => { - let result = bottles.filter((bottle) => { + const result = bottles.filter((bottle) => { + const searchLower = searchQuery.toLowerCase(); + const tastingNotesMatch = bottle.tastings?.some((t: any) => + (t.nose_notes?.toLowerCase().includes(searchLower)) || + (t.palate_notes?.toLowerCase().includes(searchLower)) || + (t.finish_notes?.toLowerCase().includes(searchLower)) + ); + const matchesSearch = - bottle.name?.toLowerCase().includes(searchQuery.toLowerCase()) || - bottle.distillery?.toLowerCase().includes(searchQuery.toLowerCase()); + bottle.name.toLowerCase().includes(searchLower) || + bottle.distillery?.toLowerCase().includes(searchLower) || + bottle.category?.toLowerCase().includes(searchLower) || + tastingNotesMatch; const matchesCategory = !selectedCategory || bottle.category === selectedCategory; const matchesDistillery = !selectedDistillery || bottle.distillery === selectedDistillery; diff --git a/src/components/DramOfTheDay.tsx b/src/components/DramOfTheDay.tsx new file mode 100644 index 0000000..0341b83 --- /dev/null +++ b/src/components/DramOfTheDay.tsx @@ -0,0 +1,101 @@ +'use client'; + +import React, { useState } from 'react'; +import { Sparkles, GlassWater, Dices, X } from 'lucide-react'; +import Link from 'next/link'; + +interface Bottle { + id: string; + name: string; + distillery?: string; + status: 'sealed' | 'open' | 'sampled' | 'empty'; +} + +interface DramOfTheDayProps { + bottles: Bottle[]; +} + +export default function DramOfTheDay({ bottles }: DramOfTheDayProps) { + const [suggestion, setSuggestion] = useState(null); + const [isRolling, setIsRolling] = useState(false); + + const suggestDram = () => { + setIsRolling(true); + const openBottles = bottles.filter(b => b.status === 'open' || b.status === 'sampled'); + + if (openBottles.length === 0) { + alert('Keine offenen Flaschen gefunden! Vielleicht Zeit für ein neues Tasting? 🥃'); + setIsRolling(false); + return; + } + + // Simulate a "roll" for 800ms + setTimeout(() => { + const randomBottle = openBottles[Math.floor(Math.random() * openBottles.length)]; + setSuggestion(randomBottle); + setIsRolling(false); + }, 800); + }; + + return ( +
+ + + {suggestion && ( +
+
+ + +
+
+ +
+ +
+

Dein heutiger Dram

+

+ {suggestion.name} +

+ {suggestion.distillery && ( +

{suggestion.distillery}

+ )} +
+ +
+ setSuggestion(null)} + className="block w-full py-4 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 rounded-2xl font-black uppercase tracking-widest text-xs hover:bg-amber-600 dark:hover:bg-amber-600 hover:text-white transition-all shadow-xl" + > + Flasche anschauen + + +
+
+
+
+ )} +
+ ); +} diff --git a/src/components/StatsDashboard.tsx b/src/components/StatsDashboard.tsx new file mode 100644 index 0000000..29c4899 --- /dev/null +++ b/src/components/StatsDashboard.tsx @@ -0,0 +1,99 @@ +'use client'; + +import React, { useMemo } from 'react'; +import { TrendingUp, CreditCard, Star, Home, BarChart3 } from 'lucide-react'; + +interface Bottle { + id: string; + purchase_price?: number | null; + status: 'sealed' | 'open' | 'sampled' | 'empty'; + distillery?: string; + tastings?: { rating: number }[]; +} + +interface StatsDashboardProps { + bottles: Bottle[]; +} + +export default function StatsDashboard({ bottles }: StatsDashboardProps) { + const stats = useMemo(() => { + const activeBottles = bottles.filter(b => b.status !== 'empty'); + const totalValue = bottles.reduce((sum, b) => sum + (Number(b.purchase_price) || 0), 0); + + const ratings = bottles.flatMap(b => b.tastings?.map(t => t.rating) || []); + const avgRating = ratings.length > 0 + ? Math.round(ratings.reduce((sum, r) => sum + r, 0) / ratings.length) + : 0; + + const distilleries = bottles + .filter(b => b.distillery) + .reduce((acc, b) => { + acc[b.distillery!] = (acc[b.distillery!] || 0) + 1; + return acc; + }, {} as Record); + + const topDistillery = Object.entries(distilleries).sort((a, b) => b[1] - a[1])[0]?.[0] || 'N/A'; + + return { + totalValue, + activeCount: activeBottles.length, + avgRating, + topDistillery, + totalCount: bottles.length + }; + }, [bottles]); + + const statItems = [ + { + label: 'Gesamtwert', + value: stats.totalValue.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' }), + icon: CreditCard, + color: 'text-green-600', + bg: 'bg-green-50 dark:bg-green-900/20' + }, + { + label: 'In der Bar', + value: stats.activeCount, + icon: Home, + color: 'text-blue-600', + bg: 'bg-blue-50 dark:bg-blue-900/20' + }, + { + label: 'Ø Bewertung', + value: `${stats.avgRating}/100`, + icon: Star, + color: 'text-amber-600', + bg: 'bg-amber-50 dark:bg-amber-900/20' + }, + { + label: 'Top Brennerei', + value: stats.topDistillery, + icon: BarChart3, + color: 'text-purple-600', + bg: 'bg-purple-50 dark:bg-purple-900/20' + }, + ]; + + return ( +
+ {statItems.map((item, idx) => { + const Icon = item.icon; + return ( +
+
+ +
+
{item.label}
+
+ {item.value} +
+ +
+ ); + })} +
+ ); +} diff --git a/src/services/update-bottle-status.ts b/src/services/update-bottle-status.ts index 8551bf2..6f0a3b9 100644 --- a/src/services/update-bottle-status.ts +++ b/src/services/update-bottle-status.ts @@ -13,7 +13,11 @@ export async function updateBottleStatus(bottleId: string, status: 'sealed' | 'o const { error } = await supabase .from('bottles') - .update({ status, updated_at: new Date().toISOString() }) + .update({ + status, + updated_at: new Date().toISOString(), + finished_at: status === 'empty' ? new Date().toISOString() : null + }) .eq('id', bottleId) .eq('user_id', session.user.id); diff --git a/supa_schema.sql b/supa_schema.sql index a08773b..30871b6 100644 --- a/supa_schema.sql +++ b/supa_schema.sql @@ -43,6 +43,7 @@ CREATE TABLE IF NOT EXISTS bottles ( purchase_price DECIMAL(10, 2), is_whisky BOOLEAN DEFAULT true, confidence INTEGER DEFAULT 100, + finished_at TIMESTAMP WITH TIME ZONE, created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now()), updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('Europe/Berlin'::text, now()) );