diff --git a/.aiideas b/.aiideas index 5a5b008..9488f81 100644 --- a/.aiideas +++ b/.aiideas @@ -1,69 +1,46 @@ -1. Feature: Tasting Notes Auto-Fill (Die "Tag-Matching" Strategie) +3. Timeline & "Flight Recorder" (Reihenfolge-Logik) -Du hast völlig recht: Wenn Gemini einfach wild Text generiert (z.B. "Grüner Granny Smith Apfel"), und deine Datenbank nur "Apfel" kennt, hast du Chaos. +Ziel: Die Geschichte des Abends rekonstruieren. Analyse des Konsumverhaltens. +Feature: Die Timeline-Ansicht -Die Lösung: "Constrained Generation" (Gezwungene Auswahl) +Statt einer einfachen Liste, eine vertikale Zeitstrahl-Ansicht. -Du fütterst Gemini nicht nur mit dem Bild, sondern auch mit deiner Master-Liste an Tags im Prompt. -Der Workflow: + 14:00 Uhr: Start der Session "Whisky Herbst". - Input: Bild vom Label + Deine Liste der System Tags (als JSON-String). + 14:15 Uhr: Glenfiddich 12 (Mild, Start). + 15:30 Uhr: Laphroaig Cask Strength (Der Gaumen-Killer). - - Frontend: + 16:00 Uhr: Auchentoshan (Schmeckt nach nichts mehr, weil Laphroaig davor war). - Die App empfängt die IDs. +Analyse & Warnungen (Smart Features): - In der UI werden diese Tags aktiviert/vorausgewählt angezeigt (z.B. farbig hinterlegt). + Der "Palette-Checker": - Der User sieht: "Vorschlag: Rauch, Vanille". + Wenn der User einen extrem rauchigen Whisky (80ppm) loggt und 10 Minuten später einen milden Lowlander eintragen will. - Wichtig: Der User kann sie abwählen (wenn er es nicht schmeckt) oder andere aus der Liste hinzufügen. + Warnung (lustig): "Achtung! Du hast gerade eine Torfbombe getrunken. Warte lieber noch 10 Min oder trink Wasser, sonst schmeckst du den nächsten nicht!" -Das ist der "Sweet Spot". Wir kombinieren die harte Fakten-Extraktion (Metadata) mit der "halluzinierten" aber kontrollierten Sensorik (Tags). + ABV-Kurve: -Hier ist dein "Master Prompt", der beides erledigt. -Das Konzept der "Constrained Generation" + Ein Liniendiagramm am Ende der Session: Wie hat sich der Alkoholgehalt entwickelt? -Wichtig: Damit Gemini nicht irgendwelche Wörter erfindet, müssen wir ihm deine Tag-Liste im Prompt mitgeben. Ich habe im Prompt einen Platzhalter {AVAILABLE_TAGS_JSON} eingefügt. Diesen musst du in deinem Code (Next.js API Route oder Edge Function) mit deiner echten Tag-Liste ersetzen, bevor du den String an Gemini schickst. -Der Prompt (Copy & Paste) + Ideal: Langsamer Anstieg. -You are a master sommelier and strict database clerk. -Your task is to analyze the whisky bottle image provided. + Gefährlich: Zick-Zack. -PART 1: METADATA EXTRACTION -Extract precise metadata from the visible label text. -- If the image is NOT a whisky bottle or if you are very unsure, set "is_whisky" to false and provide a low "confidence" score. -- If a value is not visible, use null. -- Infer the 'Category' (e.g., Islay Single Malt, Bourbon, Rye) based on the Distillery if possible. -- Search specifically for a "Whiskybase ID" or "WB ID" on the label (often handwritten or small print). -- Search for "Bottle Codes" (Laser codes often on the glass). + Time-Stamping: -PART 2: SENSORY ANALYSIS (AUTO-FILL) -Based on the identified bottle (using your internal knowledge about this specific release/distillery), select the most appropriate flavor tags. -CONSTRAINT: You must ONLY select tags from the following provided list. Do NOT invent new tags. -If you recognize the whisky, try to select 3-6 tags that best describe its character. + Nutze nicht nur created_at (Upload Zeit), sondern speichere explizit tasted_at. -AVAILABLE TAGS LIST: -{AVAILABLE_TAGS_JSON} + Warum? Wenn du 3 Stunden offline warst und dann online gehst, haben alle 5 Whiskys das gleiche created_at (Upload-Zeitpunkt). Du brauchst den Zeitpunkt, an dem der Button gedrückt wurde (lokale Handy-Zeit). -PART 3: OUTPUT -Output strictly raw JSON matching the following schema (no markdown, no code blocks): +Zusammenfassung für die Session-Logik: -{ - "name": string | null, - "distillery": string | null, - "category": string | null, - "abv": number | null, - "age": number | null, - "vintage": string | null, - "bottleCode": string | null, - "whiskybaseId": string | null, - "distilled_at": string | null, - "bottled_at": string | null, - "batch_info": string | null, - "is_whisky": boolean, - "confidence": number, - "suggested_tags": string[] -} \ No newline at end of file +Das Datenmodell muss wissen: + + session_start (Zeitstempel) + + session_end (Zeitstempel) + + Innerhalb der Session: Relative Zeit ("Dram Nr. 3, +45min nach Start"). \ No newline at end of file diff --git a/flight_recorder_migration.sql b/flight_recorder_migration.sql new file mode 100644 index 0000000..d9248bf --- /dev/null +++ b/flight_recorder_migration.sql @@ -0,0 +1,13 @@ +-- Migration: Flight Recorder Support +ALTER TABLE tasting_sessions +ADD COLUMN IF NOT EXISTS started_at TIMESTAMP WITH TIME ZONE, +ADD COLUMN IF NOT EXISTS ended_at TIMESTAMP WITH TIME ZONE; + +ALTER TABLE tastings +ADD COLUMN IF NOT EXISTS tasted_at TIMESTAMP WITH TIME ZONE; + +-- Backfill: Nutze created_at für bestehende Tastings +UPDATE tastings SET tasted_at = created_at WHERE tasted_at IS NULL; + +-- Index für schnelleres Sortieren der Timeline +CREATE INDEX IF NOT EXISTS idx_tastings_tasted_at ON tastings(tasted_at); diff --git a/src/app/bottles/[id]/page.tsx b/src/app/bottles/[id]/page.tsx index d21aba2..90ef311 100644 --- a/src/app/bottles/[id]/page.tsx +++ b/src/app/bottles/[id]/page.tsx @@ -1,232 +1,51 @@ -import { createClient } from '@/lib/supabase/server'; -import { notFound } from 'next/navigation'; -import Link from 'next/link'; -import { ChevronLeft, Calendar, Award, Droplets, MapPin, Tag, ExternalLink, Package, PlusCircle, Info } from 'lucide-react'; -import { getStorageUrl } from '@/lib/supabase'; -import TastingNoteForm from '@/components/TastingNoteForm'; -import TastingList from '@/components/TastingList'; -import DeleteBottleButton from '@/components/DeleteBottleButton'; -import EditBottleForm from '@/components/EditBottleForm'; +'use client'; + +import React, { useEffect, useState } from 'react'; +import BottleDetails from '@/components/BottleDetails'; +import { createClient } from '@/lib/supabase/client'; import { validateSession } from '@/services/validate-session'; +import { useParams, useSearchParams } from 'next/navigation'; -export default async function BottlePage(props: { - params: Promise<{ id: string }>, - searchParams: Promise<{ session_id?: string }> -}) { - const params = await props.params; - const searchParams = await props.searchParams; - let sessionId = searchParams.session_id; +export default function BottlePage() { + const params = useParams(); + const searchParams = useSearchParams(); + const [sessionId, setSessionId] = useState(undefined); + const [userId, setUserId] = useState(undefined); + const supabase = createClient(); - // Validate Session Age (12 hour limit) - if (sessionId) { - const isValid = await validateSession(sessionId); - if (!isValid) { - sessionId = undefined; - } - } - const supabase = await createClient(); - const { data: { user } } = await supabase.auth.getUser(); + const bottleId = params?.id as string; + const rawSessionId = searchParams?.get('session_id'); - const { data: bottle } = await supabase - .from('bottles') - .select('*') - .eq('id', params.id) - .single(); + useEffect(() => { + const checkSession = async () => { + if (rawSessionId) { + const isValid = await validateSession(rawSessionId); + if (isValid) { + setSessionId(rawSessionId); + } + } + }; - if (!bottle) { - notFound(); - } + const getAuth = async () => { + const { data: { user } } = await supabase.auth.getUser(); + if (user) { + setUserId(user.id); + } + }; - const tastingsResult = await supabase - .from('tastings') - .select(` - *, - tasting_sessions ( - id, - name - ), - tasting_buddies ( - buddies ( - id, - name - ) - ), - tasting_tags ( - tags ( - id, - name, - category, - is_system_default - ) - ) - `) - .eq('bottle_id', params.id) - .order('created_at', { ascending: false }); + checkSession(); + getAuth(); + }, [rawSessionId, supabase]); - let tastings = tastingsResult.data; - - if (tastingsResult.error) { - console.error('Error fetching tastings with sessions:', tastingsResult.error); - // Fallback: try without session join if relationship is missing - const fallbackResult = await supabase - .from('tastings') - .select(` - *, - tasting_tags ( - tags ( - id, - name, - category, - is_system_default - ) - ) - `) - .eq('bottle_id', params.id) - .order('created_at', { ascending: false }); - - tastings = fallbackResult.data; - } + if (!bottleId) return null; return (
-
- {/* Back Button */} - - - Zurück zur Sammlung - - - - {/* Hero Section */} -
-
- {bottle.name} -
- -
-
-

- {bottle.name} -

-

{bottle.distillery}

- - {bottle.whiskybase_id && ( - - )} -
- -
-
-
-
- Kategorie -
-
{bottle.category || '-'}
-
-
-
-
- Alkoholgehalt -
-
{bottle.abv}% Vol.
-
-
-
- Alter -
-
{bottle.age ? `${bottle.age} J.` : '-'}
-
- - {bottle.distilled_at && ( -
-
- Destilliert -
-
{bottle.distilled_at}
-
- )} - - {bottle.bottled_at && ( -
-
- Abgefüllt -
-
{bottle.bottled_at}
-
- )} - - {bottle.batch_info && ( -
-
- Batch / Code -
-
{bottle.batch_info}
-
- )} - -
-
- Letzter Dram -
-
- {tastings && tastings.length > 0 - ? new Date(tastings[0].created_at).toLocaleDateString('de-DE') - : 'Noch nie'} -
-
-
- -
- - -
-
-
- -
- - {/* Tasting Notes Section */} -
-
-
-

Tasting Notes

-

Hier findest du deine bisherigen Eindrücke.

-
-
- -
- {/* Form */} -
-

- Dram bewerten -

- -
- - {/* List */} -
- -
-
-
-
+
); } diff --git a/src/app/sessions/[id]/page.tsx b/src/app/sessions/[id]/page.tsx index 721bf1b..a14b905 100644 --- a/src/app/sessions/[id]/page.tsx +++ b/src/app/sessions/[id]/page.tsx @@ -9,6 +9,8 @@ import { deleteSession } from '@/services/delete-session'; import { useSession } from '@/context/SessionContext'; import { useParams, useRouter } from 'next/navigation'; import { useI18n } from '@/i18n/I18nContext'; +import SessionTimeline from '@/components/SessionTimeline'; +import SessionABVCurve from '@/components/SessionABVCurve'; interface Buddy { id: string; @@ -31,12 +33,20 @@ interface Session { interface SessionTasting { id: string; rating: number; + tasted_at: string; bottles: { id: string; name: string; distillery: string; image_url?: string | null; + abv: number; + category?: string; }; + tasting_tags: { + tags: { + name: string; + }; + }[]; } export default function SessionDetailPage() { @@ -84,15 +94,17 @@ export default function SessionDetailPage() { // Fetch Tastings in this session const { data: tastingData } = await supabase .from('tastings') - .select('id, rating, bottles(id, name, distillery, image_url)') + .select(` + id, + rating, + tasted_at, + bottles(id, name, distillery, image_url, abv, category), + tasting_tags(tags(name)) + `) .eq('session_id', id) - .order('created_at', { ascending: false }); + .order('tasted_at', { ascending: true }); - setTastings((tastingData as any)?.map((t: any) => ({ - id: t.id, - rating: t.rating, - bottles: t.bottles - })) || []); + setTastings((tastingData as any) || []); // Fetch all buddies for the picker const { data: buddies } = await supabase @@ -323,6 +335,17 @@ export default function SessionDetailPage() { + + {/* ABV Curve */} + {tastings.length > 0 && ( + ({ + id: t.id, + abv: t.bottles.abv || 40, + tasted_at: t.tasted_at + }))} + /> + )} {/* Main Content: Bottle List */} @@ -342,33 +365,18 @@ export default function SessionDetailPage() { - {tastings.length === 0 ? ( -
- Noch keine Flaschen in dieser Session verkostet. 🥃 -
- ) : ( -
- {tastings.map((t) => ( -
-
-
{t.bottles.distillery}
-
{t.bottles.name}
-
-
-
- {t.rating}/100 -
- - - -
-
- ))} -
- )} + ({ + id: t.id, + bottle_id: t.bottles.id, + bottle_name: t.bottles.name, + tasted_at: t.tasted_at, + rating: t.rating, + tags: t.tasting_tags?.map((tg: any) => tg.tags.name) || [], + category: t.bottles.category + }))} + sessionStart={session.scheduled_at} // Fallback to scheduled time if no started_at + /> diff --git a/src/components/BottleDetails.tsx b/src/components/BottleDetails.tsx new file mode 100644 index 0000000..23cada4 --- /dev/null +++ b/src/components/BottleDetails.tsx @@ -0,0 +1,209 @@ +'use client'; + +import React from 'react'; +import Link from 'next/link'; +import { ChevronLeft, Calendar, Award, Droplets, MapPin, Tag, ExternalLink, Package, Info, Loader2, WifiOff } from 'lucide-react'; +import { getStorageUrl } from '@/lib/supabase'; +import TastingNoteForm from '@/components/TastingNoteForm'; +import TastingList from '@/components/TastingList'; +import DeleteBottleButton from '@/components/DeleteBottleButton'; +import EditBottleForm from '@/components/EditBottleForm'; +import { useBottleData } from '@/hooks/useBottleData'; +import { useI18n } from '@/i18n/I18nContext'; + +interface BottleDetailsProps { + bottleId: string; + sessionId?: string; + userId?: string; +} + +export default function BottleDetails({ bottleId, sessionId, userId }: BottleDetailsProps) { + const { t } = useI18n(); + const { bottle, tastings, loading, error, isOffline } = useBottleData(bottleId); + + if (loading) { + return ( +
+ +

{t('common.loading')}

+
+ ); + } + + if (!bottle && !loading) { + return ( +
+
+ +
+
+

Flasche nicht verfügbar

+

+ Inhalte konnten nicht geladen werden. Bitte stelle eine Internetverbindung her, um diese Flasche zum ersten Mal zu laden. +

+
+ + Zurück zum Vault + +
+ ); + } + + if (!bottle) return null; // Should not happen due to check above + + return ( +
+ {/* Back Button */} + + + Zurück zur Sammlung + + + {isOffline && ( +
+ +

Offline-Modus: Daten aus dem Cache

+
+ )} + + {/* Hero Section */} +
+
+ {bottle.name} +
+ +
+
+

+ {bottle.name} +

+

{bottle.distillery}

+ + {bottle.whiskybase_id && ( + + )} +
+ +
+
+
+
+ Kategorie +
+
{bottle.category || '-'}
+
+
+
+
+ Alkoholgehalt +
+
{bottle.abv}% Vol.
+
+
+
+ Alter +
+
{bottle.age ? `${bottle.age} J.` : '-'}
+
+ + {bottle.distilled_at && ( +
+
+ Destilliert +
+
{bottle.distilled_at}
+
+ )} + + {bottle.bottled_at && ( +
+
+ Abgefüllt +
+
{bottle.bottled_at}
+
+ )} + + {bottle.batch_info && ( +
+
+ Batch / Code +
+
{bottle.batch_info}
+
+ )} + +
+
+ Letzter Dram +
+
+ {tastings && tastings.length > 0 + ? new Date(tastings[0].created_at).toLocaleDateString('de-DE') + : 'Noch nie'} +
+
+
+ +
+ {isOffline ? ( +
+ + Bearbeiten & Löschen nur online möglich +
+ ) : ( + <> + + + + )} +
+
+
+ +
+ + {/* Tasting Notes Section */} +
+
+
+

Tasting Notes

+

Hier findest du deine bisherigen Eindrücke.

+
+
+ +
+ {/* Form */} +
+

+ Dram bewerten +

+ +
+ + {/* List */} +
+ +
+
+
+
+ ); +} diff --git a/src/components/CameraCapture.tsx b/src/components/CameraCapture.tsx index a4aff24..05deb46 100644 --- a/src/components/CameraCapture.tsx +++ b/src/components/CameraCapture.tsx @@ -126,6 +126,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS if (!navigator.onLine) { console.log('Offline detected. Queuing image...'); await db.pending_scans.add({ + temp_id: crypto.randomUUID(), imageBase64: compressedBase64, timestamp: Date.now(), provider: aiProvider, @@ -167,6 +168,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS if (isNetworkError) { console.log('Network issue detected during scan. Queuing...'); await db.pending_scans.add({ + temp_id: crypto.randomUUID(), imageBase64: compressedBase64, timestamp: Date.now(), provider: aiProvider, diff --git a/src/components/SessionABVCurve.tsx b/src/components/SessionABVCurve.tsx new file mode 100644 index 0000000..47fdc06 --- /dev/null +++ b/src/components/SessionABVCurve.tsx @@ -0,0 +1,133 @@ +'use client'; + +import React from 'react'; +import { Activity, AlertCircle, TrendingUp, Zap } from 'lucide-react'; + +interface ABVTasting { + id: string; + abv: number; + tasted_at: string; +} + +interface SessionABVCurveProps { + tastings: ABVTasting[]; +} + +export default function SessionABVCurve({ tastings }: SessionABVCurveProps) { + if (!tastings || tastings.length < 2) { + return ( +
+ +

Kurve wird ab 2 Drams berechnet

+
+ ); + } + + const sorted = [...tastings].sort((a, b) => new Date(a.tasted_at).getTime() - new Date(b.tasted_at).getTime()); + + // Normalize data: Y-axis is ABV (say 40-65 range), X-axis is time or just sequence index + const minAbv = Math.min(...sorted.map(t => t.abv)); + const maxAbv = Math.max(...sorted.map(t => t.abv)); + const range = Math.max(maxAbv - minAbv, 10); // at least 10 point range for scale + + // SVG Dimensions + const width = 400; + const height = 150; + const padding = 20; + + const getX = (index: number) => padding + (index * (width - 2 * padding) / (sorted.length - 1)); + const getY = (abv: number) => { + const normalized = (abv - (minAbv - 2)) / (range + 4); + return height - padding - (normalized * (height - 2 * padding)); + }; + + const points = sorted.map((t, i) => `${getX(i)},${getY(t.abv)}`).join(' '); + + // Check for dangerous slope (sudden high ABV jump) + const hasBigJump = sorted.some((t, i) => i > 0 && t.abv - sorted[i - 1].abv > 10); + + return ( +
+
+
+ +
+

ABV Kurve (Session)

+

Alcohol By Volume Progression

+
+
+ {hasBigJump && ( +
+ + Zick-Zack Gefahr +
+ )} +
+ +
+ {/* Grid Lines */} +
+ {[1, 2, 3, 4].map(i =>
)} +
+ + + {/* Gradient under line */} + + + + + + + + + + + + {sorted.map((t, i) => ( + + + + {t.abv}% + + + ))} + +
+ +
+
+ Ø Alkohol + {(sorted.reduce((acc, t) => acc + t.abv, 0) / sorted.length).toFixed(1)}% +
+
+ Status + + {hasBigJump ? 'Instabil' : 'Optimal'} + +
+
+
+ ); +} diff --git a/src/components/SessionTimeline.tsx b/src/components/SessionTimeline.tsx new file mode 100644 index 0000000..98fdb82 --- /dev/null +++ b/src/components/SessionTimeline.tsx @@ -0,0 +1,118 @@ +'use client'; + +import React from 'react'; +import { CheckCircle2, AlertTriangle, Clock, Droplets, Info } from 'lucide-react'; +import Link from 'next/link'; + +interface TimelineTasting { + id: string; + bottle_id: string; + bottle_name: string; + tasted_at: string; + rating: number; + tags: string[]; + category?: string; +} + +interface SessionTimelineProps { + tastings: TimelineTasting[]; + sessionStart?: string; +} + +// Keywords that indicate a "Peat Bomb" +const SMOKY_KEYWORDS = ['rauch', 'torf', 'smoke', 'peat', 'islay', 'ash', 'lagerfeuer', 'campfire', 'asphalte']; + +export default function SessionTimeline({ tastings, sessionStart }: SessionTimelineProps) { + if (!tastings || tastings.length === 0) { + return ( +
+ +

Noch keine Dram-Historie vorhanden.

+
+ ); + } + + // Sort by tasted_at + const sortedTastings = [...tastings].sort((a, b) => + new Date(a.tasted_at).getTime() - new Date(b.tasted_at).getTime() + ); + + const firstTastingTime = sessionStart ? new Date(sessionStart).getTime() : new Date(sortedTastings[0].tasted_at).getTime(); + + const checkIsSmoky = (tasting: TimelineTasting) => { + const textToSearch = (tasting.tags.join(' ') + ' ' + (tasting.category || '')).toLowerCase(); + return SMOKY_KEYWORDS.some(keyword => textToSearch.includes(keyword)); + }; + + return ( +
+ {sortedTastings.map((tasting, index) => { + const currentTime = new Date(tasting.tasted_at).getTime(); + const diffMinutes = Math.round((currentTime - firstTastingTime) / (1000 * 60)); + const isSmoky = checkIsSmoky(tasting); + + // Palette warning logic: if this dram is peaty, warn about the NEXT one (metaphorically) + // Or if the PREVIOUS was peaty, show a warning on this one. + const wasPreviousSmoky = index > 0 && checkIsSmoky(sortedTastings[index - 1]); + const timeSinceLastDram = index > 0 + ? Math.round((currentTime - new Date(sortedTastings[index - 1].tasted_at).getTime()) / (1000 * 60)) + : 0; + + return ( +
+ {/* Dot */} +
+ {isSmoky && } +
+ + {/* Relative Time */} +
+ + {index === 0 ? 'START' : `+${diffMinutes}'`} + +
+ +
+
+
+
+ Dram #{index + 1} + {isSmoky && ( + Peat Bomb + )} +
+ + {tasting.bottle_name} + +
+ {tasting.tags.slice(0, 3).map(tag => ( + + {tag} + + ))} +
+
+
+
{tasting.rating}
+
Punkte
+
+
+ + {wasPreviousSmoky && timeSinceLastDram < 20 && ( +
+ +

+ Achtung: Gaumen war noch torf-belegt (nur {timeSinceLastDram}m Abstand). +

+
+ )} +
+
+ ); + })} +
+ ); +} diff --git a/src/components/TastingNoteForm.tsx b/src/components/TastingNoteForm.tsx index 49ecd2b..c1e4447 100644 --- a/src/components/TastingNoteForm.tsx +++ b/src/components/TastingNoteForm.tsx @@ -9,6 +9,7 @@ import { useSession } from '@/context/SessionContext'; import TagSelector from './TagSelector'; import { useLiveQuery } from 'dexie-react-hooks'; import { db } from '@/lib/db'; +import { AlertTriangle } from 'lucide-react'; interface Buddy { id: string; @@ -16,11 +17,13 @@ interface Buddy { } interface TastingNoteFormProps { - bottleId: string; + bottleId?: string; // Real ID + pendingBottleId?: string; // Temp ID for queued scans sessionId?: string; + onSuccess?: () => void; } -export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteFormProps) { +export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId, onSuccess }: TastingNoteFormProps) { const { t } = useI18n(); const supabase = createClient(); const [rating, setRating] = useState(85); @@ -38,11 +41,15 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm const [suggestedTags, setSuggestedTags] = useState([]); const [suggestedCustomTags, setSuggestedCustomTags] = useState([]); const { activeSession } = useSession(); + const [lastDramInSession, setLastDramInSession] = useState<{ name: string; isSmoky: boolean; timestamp: number } | null>(null); + const [showPaletteWarning, setShowPaletteWarning] = useState(false); const effectiveSessionId = sessionId || activeSession?.id; useEffect(() => { const fetchData = async () => { + if (!bottleId) return; + // Fetch Bottle Suggestions const { data: bottleData } = await supabase .from('bottles') @@ -57,7 +64,7 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm setSuggestedCustomTags(bottleData.suggested_custom_tags); } - // If Session ID, fetch session participants and pre-select them + // If Session ID, fetch session participants and pre-select them, and fetch last dram if (effectiveSessionId) { const { data: participants } = await supabase .from('session_participants') @@ -67,13 +74,59 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm if (participants) { setSelectedBuddyIds(participants.map(p => p.buddy_id)); } + + // Fetch last dram for Palette Checker (Only online) + if (navigator.onLine) { + const { data: lastTastings } = await supabase + .from('tastings') + .select(` + id, + tasted_at, + bottles(name, category), + tasting_tags(tags(name)) + `) + .eq('session_id', effectiveSessionId) + .order('tasted_at', { ascending: false }) + .limit(1); + + if (lastTastings && lastTastings.length > 0) { + const last = lastTastings[0]; + const tags = (last as any).tasting_tags?.map((t: any) => t.tags.name) || []; + const category = (last as any).bottles?.category || ''; + const text = (tags.join(' ') + ' ' + category).toLowerCase(); + const smokyKeywords = ['rauch', 'torf', 'smoke', 'peat', 'islay', 'ash', 'lagerfeuer']; + const isSmoky = smokyKeywords.some(kw => text.includes(kw)); + + setLastDramInSession({ + name: (last as any).bottles?.name || 'Unbekannt', + isSmoky, + timestamp: new Date(last.tasted_at).getTime() + }); + } + } } else { setSelectedBuddyIds([]); + setLastDramInSession(null); } }; fetchData(); }, [supabase, effectiveSessionId, bottleId]); + // Live Palette Checker Logic + useEffect(() => { + if (lastDramInSession?.isSmoky) { + const now = Date.now(); + const diffMin = (now - lastDramInSession.timestamp) / (1000 * 60); + if (diffMin < 20) { + setShowPaletteWarning(true); + } else { + setShowPaletteWarning(false); + } + } else { + setShowPaletteWarning(false); + } + }, [lastDramInSession]); + const toggleBuddy = (id: string) => { setSelectedBuddyIds(prev => prev.includes(id) ? prev.filter(bid => bid !== id) : [...prev, id] @@ -109,7 +162,7 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm setError(null); const data = { - bottle_id: bottleId, + bottle_id: bottleId as string, session_id: effectiveSessionId, rating, nose_notes: nose, @@ -121,15 +174,17 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm }; try { - if (!navigator.onLine) { + if (!navigator.onLine || pendingBottleId) { // Save to Offline DB await db.pending_tastings.add({ bottle_id: bottleId, + pending_bottle_id: pendingBottleId, data, tasted_at: new Date().toISOString() }); clearForm(); setLoading(false); + onSuccess?.(); return; } @@ -137,6 +192,7 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm if (result.success) { clearForm(); + onSuccess?.(); } else { setError(result.error || t('common.error')); } @@ -161,6 +217,28 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
)} + {showPaletteWarning && ( +
+ +
+

Palette-Checker Warnung

+

+ Dein letzter Dram war "{lastDramInSession?.name}". +

+

+ Da er sehr torfig war und erst vor Kurzem verkostet wurde, könnten deine Geschmacksnerven noch beeinträchtigt sein. Trink am besten etwas Wasser! +

+ +
+
+ )} +