+
{suggestedCustomTagNames
.filter(name => !(tags || []).some(t => t.name.toLowerCase() === name.toLowerCase()))
.map(name => (
@@ -177,7 +184,7 @@ export default function TagSelector({ category, selectedTagIds, onToggleTag, lab
}
setCreatingSuggestion(null);
}}
- className="px-2.5 py-1 rounded-lg bg-zinc-900/50 text-zinc-400 text-[10px] font-bold uppercase tracking-tight hover:bg-orange-600 hover:text-white transition-all border border-dashed border-zinc-800 flex items-center gap-1.5 disabled:opacity-50"
+ className="px-3 py-1.5 rounded-xl bg-zinc-950/50 text-zinc-500 text-[10px] font-black uppercase tracking-tight hover:bg-zinc-800 hover:text-zinc-200 transition-all border border-dashed border-zinc-800 flex items-center gap-1.5 disabled:opacity-50"
>
{creatingSuggestion === name ?
:
}
{name}
@@ -187,7 +194,7 @@ export default function TagSelector({ category, selectedTagIds, onToggleTag, lab
)}
- {/* Suggestions Chips (limit to 6 random or most common) */}
+ {/* Suggestions Chips */}
{!search && (tags || []).length > 0 && (
{(tags || [])
@@ -198,7 +205,7 @@ export default function TagSelector({ category, selectedTagIds, onToggleTag, lab
key={tag.id}
type="button"
onClick={() => onToggleTag(tag.id)}
- className="px-2.5 py-1 rounded-lg bg-zinc-900 text-zinc-500 text-[10px] font-bold uppercase tracking-tight hover:bg-zinc-800 hover:text-zinc-200 transition-colors border border-zinc-800"
+ className="px-2.5 py-1.5 rounded-xl bg-zinc-900 text-zinc-500 text-[10px] font-bold uppercase tracking-tight hover:bg-zinc-800 hover:text-zinc-300 transition-colors border border-zinc-800/50"
>
{tag.is_system_default ? t(`aroma.${tag.name}`) : tag.name}
diff --git a/src/components/TastingEditor.tsx b/src/components/TastingEditor.tsx
index c4d52b2..bf33b36 100644
--- a/src/components/TastingEditor.tsx
+++ b/src/components/TastingEditor.tsx
@@ -2,13 +2,14 @@
import React, { useState, useEffect } from 'react';
import { motion } from 'framer-motion';
-import { ChevronDown, Wind, Utensils, Droplets, Sparkles, Send, Users, Star, AlertTriangle, Check, Zap } from 'lucide-react';
+import { ChevronDown, Wind, Utensils, Droplets, Sparkles, Send, Users, Star, AlertTriangle, Check, Zap, Loader2 } from 'lucide-react';
import { BottleMetadata } from '@/types/whisky';
import TagSelector from './TagSelector';
import { useLiveQuery } from 'dexie-react-hooks';
import { db } from '@/lib/db';
import { createClient } from '@/lib/supabase/client';
import { useI18n } from '@/i18n/I18nContext';
+import { discoverWhiskybaseId } from '@/services/discover-whiskybase';
interface TastingEditorProps {
bottleMetadata: BottleMetadata;
@@ -17,9 +18,10 @@ interface TastingEditorProps {
onOpenSessions: () => void;
activeSessionName?: string;
activeSessionId?: string;
+ isEnriching?: boolean;
}
-export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSessions, activeSessionName, activeSessionId }: TastingEditorProps) {
+export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSessions, activeSessionName, activeSessionId, isEnriching }: TastingEditorProps) {
const { t } = useI18n();
const supabase = createClient();
const [rating, setRating] = useState(85);
@@ -38,6 +40,27 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
const [noseTagIds, setNoseTagIds] = useState
([]);
const [palateTagIds, setPalateTagIds] = useState([]);
const [finishTagIds, setFinishTagIds] = useState([]);
+
+ // Editable bottle metadata
+ const [bottleName, setBottleName] = useState(bottleMetadata.name || '');
+ const [bottleDistillery, setBottleDistillery] = useState(bottleMetadata.distillery || '');
+ const [bottleAbv, setBottleAbv] = useState(bottleMetadata.abv?.toString() || '');
+ const [bottleAge, setBottleAge] = useState(bottleMetadata.age?.toString() || '');
+ const [bottleCategory, setBottleCategory] = useState(bottleMetadata.category || 'Whisky');
+
+ const [bottleVintage, setBottleVintage] = useState(bottleMetadata.vintage || '');
+ const [bottleBottler, setBottleBottler] = useState(bottleMetadata.bottler || '');
+ const [bottleBatchInfo, setBottleBatchInfo] = useState(bottleMetadata.batch_info || '');
+ const [bottleCode, setBottleCode] = useState(bottleMetadata.bottleCode || '');
+ const [bottleDistilledAt, setBottleDistilledAt] = useState(bottleMetadata.distilled_at || '');
+ const [bottleBottledAt, setBottleBottledAt] = useState(bottleMetadata.bottled_at || '');
+ const [showBottleDetails, setShowBottleDetails] = useState(false);
+
+ // Whiskybase discovery
+ const [whiskybaseId, setWhiskybaseId] = useState(bottleMetadata.whiskybaseId || '');
+ const [whiskybaseDiscovery, setWhiskybaseDiscovery] = useState<{ id: string; url: string; title: string } | null>(null);
+ const [isDiscoveringWb, setIsDiscoveringWb] = useState(false);
+ const [whiskybaseError, setWhiskybaseError] = useState(null);
const [textureTagIds, setTextureTagIds] = useState([]);
const [selectedBuddyIds, setSelectedBuddyIds] = useState([]);
@@ -100,6 +123,42 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
}
}, [lastDramInSession]);
+ // Automatic Whiskybase discovery when details are expanded
+ useEffect(() => {
+ const searchWhiskybase = async () => {
+ if (showBottleDetails && !whiskybaseId && !whiskybaseDiscovery && !isDiscoveringWb) {
+ setIsDiscoveringWb(true);
+ try {
+ const result = await discoverWhiskybaseId({
+ name: bottleMetadata.name || '',
+ distillery: bottleMetadata.distillery ?? undefined,
+ abv: bottleMetadata.abv ?? undefined,
+ age: bottleMetadata.age ?? undefined,
+ batch_info: bottleMetadata.batch_info ?? undefined,
+ distilled_at: bottleMetadata.distilled_at ?? undefined,
+ bottled_at: bottleMetadata.bottled_at ?? undefined,
+ });
+
+ if (result.success && result.id) {
+ setWhiskybaseDiscovery({ id: result.id, url: result.url, title: result.title });
+ setWhiskybaseId(result.id);
+ setWhiskybaseError(null);
+ } else {
+ // No results found
+ setWhiskybaseError('Keine Ergebnisse gefunden');
+ }
+ } catch (err: any) {
+ console.error('[TastingEditor] Whiskybase discovery failed:', err);
+ setWhiskybaseError(err.message || 'Suche fehlgeschlagen');
+ } finally {
+ setIsDiscoveringWb(false);
+ }
+ }
+ };
+
+ searchWhiskybase();
+ }, [showBottleDetails]); // Only trigger when details are expanded
+
const toggleBuddy = (id: string) => {
setSelectedBuddyIds(prev => prev.includes(id) ? prev.filter(bid => bid !== id) : [...prev, id]);
};
@@ -118,27 +177,29 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
taste: tasteScore,
finish: finishScore,
complexity: complexityScore,
- balance: balanceScore
+ balance: balanceScore,
+ // Edited bottle metadata
+ bottleMetadata: {
+ ...bottleMetadata,
+ name: bottleName || bottleMetadata.name,
+ distillery: bottleDistillery || bottleMetadata.distillery,
+ abv: bottleAbv ? parseFloat(bottleAbv) : bottleMetadata.abv,
+ age: bottleAge ? parseInt(bottleAge) : bottleMetadata.age,
+ category: bottleCategory || bottleMetadata.category,
+ vintage: bottleVintage || null,
+ bottler: bottleBottler || null,
+ batch_info: bottleBatchInfo || null,
+ bottleCode: bottleCode || null,
+ distilled_at: bottleDistilledAt || null,
+ bottled_at: bottleBottledAt || null,
+ whiskybaseId: whiskybaseId || null,
+ }
});
};
return (
- {/* Top Context Bar - Flex Child 1 */}
-
-
-
-
- {/* Main Scrollable Content - Flex Child 2 */}
+ {/* Main Scrollable Content */}
{/* Palette Warning */}
@@ -170,15 +231,254 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
- {bottleMetadata.distillery || 'Destillerie'}
+ {bottleDistillery || 'Destillerie'}
-
{bottleMetadata.name || 'Unbekannter Malt'}
+
{bottleName || 'Unbekannter Malt'}
- {bottleMetadata.category || 'Whisky'} {bottleMetadata.abv ? `• ${bottleMetadata.abv}%` : ''} {bottleMetadata.age ? `• ${bottleMetadata.age}y` : ''}
+ {bottleCategory || 'Whisky'} {bottleAbv ? `• ${bottleAbv}%` : ''} {bottleAge ? `• ${bottleAge}y` : ''}
+ {/* Expandable Bottle Details */}
+
+
+
+ {showBottleDetails && (
+
+ {/* Name */}
+
+
+ setBottleName(e.target.value)}
+ placeholder="e.g. 12 Year Old"
+ className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
+ />
+
+
+ {/* Distillery */}
+
+
+ setBottleDistillery(e.target.value)}
+ placeholder="e.g. Lagavulin"
+ className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
+ />
+
+
+
+
+ {/* Category */}
+
+
+ setBottleCategory(e.target.value)}
+ placeholder="e.g. Single Malt"
+ className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
+ />
+
+ {/* Vintage */}
+
+
+ setBottleVintage(e.target.value)}
+ placeholder="e.g. 2007"
+ className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
+ />
+
+
+ {/* Bottler */}
+
+
+ setBottleBottler(e.target.value)}
+ placeholder="e.g. Independent Bottler"
+ className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
+ />
+
+
+ {/* Distilled At */}
+
+
+ setBottleDistilledAt(e.target.value)}
+ placeholder="e.g. 2007"
+ className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
+ />
+
+
+ {/* Bottled At */}
+
+
+ setBottleBottledAt(e.target.value)}
+ placeholder="e.g. 2024"
+ className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
+ />
+
+
+ {/* Batch Info */}
+
+
+ setBottleBatchInfo(e.target.value)}
+ placeholder="e.g. Oloroso Sherry Cask"
+ className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors"
+ />
+
+
+ {/* Bottle Code */}
+
+
+ setBottleCode(e.target.value)}
+ placeholder="e.g. WB271235"
+ className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 font-mono focus:outline-none focus:border-orange-600 transition-colors"
+ />
+
+
+ {/* Whiskybase Discovery */}
+ {isDiscoveringWb && (
+
+
+ Searching Whiskybase...
+
+ )}
+
+ {whiskybaseDiscovery && (
+
+
+
+ ID: {whiskybaseDiscovery.id}
+
+
+ )}
+
+ {/* Whiskybase Error */}
+ {whiskybaseError && !whiskybaseDiscovery && !isDiscoveringWb && (
+
+
+ {whiskybaseError}
+
+
+ )}
+
+ )}
+
+
+ {/* Session Selector */}
+
+
{/* Rating Slider */}
@@ -252,6 +552,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
onToggleTag={(id) => setNoseTagIds(prev => prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id])}
suggestedTagNames={suggestedTags}
suggestedCustomTagNames={suggestedCustomTags}
+ isLoading={isEnriching}
/>
@@ -289,6 +590,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
onToggleTag={(id) => setPalateTagIds(prev => prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id])}
suggestedTagNames={suggestedTags}
suggestedCustomTagNames={suggestedCustomTags}
+ isLoading={isEnriching}
/>
@@ -383,19 +685,19 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
)}
+
- {/* Sticky Footer - Flex Child 3 */}
-
-
-
-
+ {/* Fixed/Sticky Footer for Save Action */}
+
+
+
diff --git a/src/components/TastingNoteForm.tsx b/src/components/TastingNoteForm.tsx
index 9e529d6..0ac72b9 100644
--- a/src/components/TastingNoteForm.tsx
+++ b/src/components/TastingNoteForm.tsx
@@ -439,18 +439,21 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
)}
-
+ {/* Sticky Save Button Container */}
+
+
+
);
}
diff --git a/src/components/UploadQueue.tsx b/src/components/UploadQueue.tsx
index c4ce4a0..6c0ab0f 100644
--- a/src/components/UploadQueue.tsx
+++ b/src/components/UploadQueue.tsx
@@ -3,13 +3,31 @@
import React, { useEffect, useState, useCallback } from 'react';
import { useLiveQuery } from 'dexie-react-hooks';
import { db, PendingScan, PendingTasting } from '@/lib/db';
-import { magicScan } from '@/services/magic-scan';
+import { scanLabel } from '@/app/actions/scan-label';
+import { enrichData } from '@/app/actions/enrich-data';
import { saveBottle } from '@/services/save-bottle';
import { saveTasting } from '@/services/save-tasting';
+
import { createClient } from '@/lib/supabase/client';
import { RefreshCw, CheckCircle2, AlertCircle, Loader2, Info, Send } from 'lucide-react';
import TastingNoteForm from './TastingNoteForm';
+// Helper to convert base64 to FormData
+function base64ToFormData(base64: string, filename: string = 'image.webp'): FormData {
+ const arr = base64.split(',');
+ const mime = arr[0].match(/:(.*?);/)?.[1] || 'image/webp';
+ const bstr = atob(arr[1]);
+ let n = bstr.length;
+ const u8arr = new Uint8Array(n);
+ while (n--) {
+ u8arr[n] = bstr.charCodeAt(n);
+ }
+ const file = new File([u8arr], filename, { type: mime });
+ const formData = new FormData();
+ formData.append('file', file);
+ return formData;
+}
+
export default function UploadQueue() {
const supabase = createClient();
const [isSyncing, setIsSyncing] = useState(false);
@@ -23,9 +41,12 @@ export default function UploadQueue() {
const totalInQueue = pendingScans.length + pendingTastings.length;
- const syncQueue = useCallback(async () => {
- if (isSyncing || !navigator.onLine || totalInQueue === 0) return;
+ const syncInProgress = React.useRef(false);
+ const syncQueue = useCallback(async () => {
+ if (syncInProgress.current || !navigator.onLine) return;
+
+ syncInProgress.current = true;
setIsSyncing(true);
const { data: { user } } = await supabase.auth.getUser();
@@ -36,61 +57,139 @@ export default function UploadQueue() {
}
try {
- // 1. Sync Scans (Magic Shots)
- for (const item of pendingScans) {
+ // 1. Sync Scans (Magic Shots) - Two-Step Flow
+ // We use a transaction to "claim" items currently not being synced by another tab/instance
+ const scansToSync = await db.transaction('rw', db.pending_scans, async () => {
+ const all = await db.pending_scans.toArray();
+ const now = Date.now();
+ const available = all.filter(i => {
+ if (i.syncing) return false;
+ // Exponential backoff: don't retry immediately if it failed before
+ if (i.attempts && i.attempts > 0) {
+ const backoff = Math.min(Math.pow(2, i.attempts) * 1000, 30000); // Max 30s
+ const lastAttempt = i.timestamp; // We use timestamp for simplicity or add last_attempt
+ // For now we trust timestamp + backoff if timestamp is updated on fail
+ return (now - i.timestamp) > backoff;
+ }
+ return true;
+ });
+ for (const item of available) {
+ await db.pending_scans.update(item.id!, { syncing: 1 });
+ }
+ return available;
+ });
+
+ for (const item of scansToSync) {
const itemId = `scan-${item.id}`;
- setCurrentProgress({ id: itemId, status: 'Analysiere Scan...' });
+ setCurrentProgress({ id: itemId, status: 'OCR Analyse...' });
try {
- const analysis = await magicScan(item.imageBase64, item.provider, item.locale);
- if (analysis.success && analysis.data) {
- const bottleData = analysis.data;
- setCurrentProgress({ id: itemId, status: 'Speichere Flasche...' });
- const save = await saveBottle(bottleData, item.imageBase64, user.id);
- if (save.success && save.data) {
- const newBottleId = save.data.id;
+ let bottleData;
- // Reconcile pending tastings linked to this temp_id
- if (item.temp_id) {
- const linkedTastings = await db.pending_tastings
- .where('pending_bottle_id')
- .equals(item.temp_id)
- .toArray();
+ // Check if this is an offline scan with pre-filled metadata
+ // CRITICAL: If name is empty, it's placeholder metadata and needs OCR enrichment
+ if (item.metadata && item.metadata.name && item.metadata.name.trim().length > 0) {
+ console.log('[UploadQueue] Valid offline metadata found - skipping OCR');
+ bottleData = item.metadata;
+ setCurrentProgress({ id: itemId, status: 'Speichere Offline-Scan...' });
+ } else {
+ console.log('[UploadQueue] No valid metadata - running OCR analysis');
+ // Normal online scan - perform AI analysis
+ // Step 1: Fast OCR
+ const formData = base64ToFormData(item.imageBase64);
+ const ocrResult = await scanLabel(formData);
- for (const lt of linkedTastings) {
- await db.pending_tastings.update(lt.id!, {
- bottle_id: newBottleId,
- pending_bottle_id: undefined
- });
+ if (ocrResult.success && ocrResult.data) {
+ bottleData = ocrResult.data;
+
+ // Step 2: Background enrichment (before saving)
+ if (bottleData.is_whisky && bottleData.name && bottleData.distillery) {
+ setCurrentProgress({ id: itemId, status: 'Enrichment...' });
+ const enrichResult = await enrichData(
+ bottleData.name,
+ bottleData.distillery,
+ undefined,
+ item.locale
+ );
+
+ if (enrichResult.success && enrichResult.data) {
+ // Merge enrichment data into bottle data
+ bottleData = {
+ ...bottleData,
+ suggested_tags: enrichResult.data.suggested_tags,
+ suggested_custom_tags: enrichResult.data.suggested_custom_tags
+ };
}
}
-
- setCompletedItems(prev => [...prev.slice(-4), {
- id: itemId,
- name: bottleData.name || 'Unbekannter Whisky',
- bottleId: newBottleId,
- type: 'scan'
- }]);
- await db.pending_scans.delete(item.id!);
+ } else {
+ throw new Error(ocrResult.error || 'Analyse fehlgeschlagen');
}
- } else {
- throw new Error(analysis.error || 'Analyse fehlgeschlagen');
+ }
+
+ // Step 3: Save bottle with all data
+ setCurrentProgress({ id: itemId, status: 'Speichere Flasche...' });
+ const save = await saveBottle(bottleData, item.imageBase64, user.id);
+
+ if (save.success && save.data) {
+ const newBottleId = save.data.id;
+
+ // Reconcile pending tastings linked to this temp_id
+ if (item.temp_id) {
+ const linkedTastings = await db.pending_tastings
+ .where('pending_bottle_id')
+ .equals(item.temp_id)
+ .toArray();
+
+ for (const lt of linkedTastings) {
+ await db.pending_tastings.update(lt.id!, {
+ bottle_id: newBottleId,
+ pending_bottle_id: undefined
+ });
+ }
+ }
+
+ setCompletedItems(prev => [...prev.slice(-4), {
+ id: itemId,
+ name: bottleData.name || 'Unbekannter Whisky',
+ bottleId: newBottleId,
+ type: 'scan'
+ }]);
+ await db.pending_scans.delete(item.id!);
}
} catch (err) {
+ const errorMessage = err instanceof Error ? err.message : 'Unbekannter Fehler';
console.error('Scan sync failed:', err);
- setCurrentProgress({ id: itemId, status: 'Fehler bei Scan' });
- // Wait a bit before next
- await new Promise(r => setTimeout(r, 2000));
+ setCurrentProgress({ id: itemId, status: `Fehler: ${errorMessage.substring(0, 20)}...` });
+ // Unmark as syncing on failure, update attempts and timestamp for backoff
+ await db.pending_scans.update(item.id!, {
+ syncing: 0,
+ attempts: (item.attempts || 0) + 1,
+ last_error: errorMessage,
+ timestamp: Date.now() // Update timestamp to use for backoff
+ });
+ await new Promise(r => setTimeout(r, 1000));
}
}
// 2. Sync Tastings
- for (const item of pendingTastings) {
- // If it still has a pending_bottle_id, it means the scan hasn't synced yet.
- // We SKIP this tasting and wait for the scan to finish in a future loop.
- if (item.pending_bottle_id) {
- continue;
+ const tastingsToSync = await db.transaction('rw', db.pending_tastings, async () => {
+ const all = await db.pending_tastings.toArray();
+ const now = Date.now();
+ const available = all.filter(i => {
+ if (i.syncing || i.pending_bottle_id) return false;
+ // Exponential backoff
+ if (i.attempts && i.attempts > 0) {
+ const backoff = Math.min(Math.pow(2, i.attempts) * 1000, 30000);
+ return (now - new Date(i.tasted_at).getTime()) > backoff;
+ }
+ return true;
+ });
+ for (const item of available) {
+ await db.pending_tastings.update(item.id!, { syncing: 1 });
}
+ return available;
+ });
+ for (const item of tastingsToSync) {
const itemId = `tasting-${item.id}`;
setCurrentProgress({ id: itemId, status: 'Synchronisiere Tasting...' });
try {
@@ -112,36 +211,61 @@ export default function UploadQueue() {
throw new Error(result.error);
}
} catch (err) {
+ const errorMessage = err instanceof Error ? err.message : 'Unbekannter Fehler';
console.error('Tasting sync failed:', err);
- setCurrentProgress({ id: itemId, status: 'Fehler bei Tasting' });
- await new Promise(r => setTimeout(r, 2000));
+ setCurrentProgress({ id: itemId, status: `Fehler: ${errorMessage.substring(0, 20)}...` });
+ await db.pending_tastings.update(item.id!, {
+ syncing: 0,
+ attempts: (item.attempts || 0) + 1,
+ last_error: errorMessage
+ // Note: we use tasted_at or add a last_attempt for backoff.
+ // For now let's just use the tried attempts as a counter and a fixed wait.
+ });
+ await new Promise(r => setTimeout(r, 1000));
}
}
} catch (err) {
console.error('Global Sync Error:', err);
} finally {
+ syncInProgress.current = false;
setIsSyncing(false);
setCurrentProgress(null);
}
- }, [isSyncing, pendingScans, pendingTastings, totalInQueue, supabase]);
+ }, [supabase]); // Removed pendingScans, pendingTastings, totalInQueue, isSyncing
useEffect(() => {
const handleOnline = () => {
- console.log('Online! Waiting 2s for network stability...');
- setTimeout(() => {
- syncQueue();
- }, 2000);
+ console.log('Online! Syncing in 2s...');
+ setTimeout(syncQueue, 2000);
};
window.addEventListener('online', handleOnline);
- // Initial check if we are online and have items
+ // Initial check: only trigger if online and items exist,
+ // and we aren't already syncing.
if (navigator.onLine && totalInQueue > 0 && !isSyncing) {
- syncQueue();
+ // we use a small timeout to debounce background sync
+ const timer = setTimeout(syncQueue, 3000);
+ return () => {
+ window.removeEventListener('online', handleOnline);
+ clearTimeout(timer);
+ };
}
return () => window.removeEventListener('online', handleOnline);
- }, [totalInQueue, syncQueue, isSyncing]);
+ // Trigger when the presence of items changes or online status
+ }, [syncQueue, totalInQueue > 0]); // Removed isSyncing to break the loop
+
+ // Clear stale syncing flags on mount
+ useEffect(() => {
+ const clearStaleFlags = async () => {
+ await db.transaction('rw', [db.pending_scans, db.pending_tastings], async () => {
+ await db.pending_scans.where('syncing').equals(1).modify({ syncing: 0 });
+ await db.pending_tastings.where('syncing').equals(1).modify({ syncing: 0 });
+ });
+ };
+ clearStaleFlags();
+ }, []);
if (totalInQueue === 0) return null;
diff --git a/src/lib/ai-prompts.ts b/src/lib/ai-prompts.ts
index c4f6982..3177ec8 100644
--- a/src/lib/ai-prompts.ts
+++ b/src/lib/ai-prompts.ts
@@ -1,46 +1,68 @@
-export const getSystemPrompt = (availableTags: string, language: string) => `
-TASK: Analyze this whisky bottle image. Return raw JSON.
+export const getOcrPrompt = () => `
+ROLE: High-Precision OCR Engine for Whisky Labels.
+OBJECTIVE: Extract visible metadata strictly from the image.
+SPEED PRIORITY: Do NOT analyze flavor. Do NOT provide descriptions. Do NOT add tags.
-STEP 1: IDENTIFICATION (OCR & EXTRACTION)
-Extract exact text and details from the label. Look closely for specific dates and codes.
-- name: Full whisky name (e.g. "Lagavulin 16 Year Old")
-- distillery: Distillery name
-- bottler: Independent bottler if applicable
-- category: Type (e.g. "Islay Single Malt", "Bourbon")
-- abv: Alcohol percentage (number only)
-- age: Age statement in years (number only)
-- vintage: Vintage year (e.g. "1995")
-- distilled_at: Distillation date/year if specified
-- bottled_at: Bottling date/year if specified
-- batch_info: Cask number, Batch ID, or Bottle number (e.g. "Batch 001", "Cask #402")
-- bottleCode: Laser codes etched on glass/label (e.g. "L1234...")
-- whiskybaseId: Whiskybase ID if clearly printed (rare, but check)
+TASK:
+1. Identify if the image contains a whisky/spirit bottle.
+2. Extract the following technical details into the JSON schema below.
+3. If a value is not visible or cannot be inferred with high certainty, use null.
-STEP 2: SENSORY "MAGIC" (KNOWLEDGE RETRIEVAL)
-Use the IDENTIFIED NAME from Step 1 to query your internal knowledge base for the flavor profile.
-DO NOT try to "see" the flavor in the pixels. Use your expert knowledge about this specific whisky edition.
-- Match flavors strictly against this list: ${availableTags}
-- Select top 5-8 matching tags.
-- If distinct notes are missing from the list, add 1-2 unique ones to "suggested_custom_tags" (localized in ${language === 'de' ? 'German' : 'English'}).
+EXTRACTION RULES:
+- Name: Combine Distillery + Age + Edition + Vintage (e.g., "Signatory Vintage Ben Nevis 2019 4 Year Old").
+- Distillery: The producer of the spirit.
+- Bottler: Independent bottler (e.g., "Signatory", "Gordon & MacPhail") if applicable.
+- Batch Info: Capture ALL Cask numbers, Batch IDs, Bottle numbers, Cask Types (e.g., "Refill Oloroso Sherry Butt, Bottle 1135").
+- Codes: Look for laser codes etched on glass/label (e.g., "L20394...").
+- Dates: Distinguish clearly between Vintage (distilled year), Bottled year, and Age.
OUTPUT SCHEMA (Strict JSON):
{
"name": "string",
"distillery": "string",
- "category": "string",
- "abv": number or null,
- "age": number or null,
- "vintage": "string or null",
- "distilled_at": "string or null",
- "bottled_at": "string or null",
- "batch_info": "string or null",
- "bottleCode": "string or null",
- "whiskybaseId": "string or null",
+ "bottler": "stringOrNull",
+ "category": "string (e.g. Single Malt Scotch Whisky)",
+ "abv": numberOrNull,
+ "age": numberOrNull,
+ "vintage": "stringOrNull",
+ "distilled_at": "stringOrNull (Year/Date)",
+ "bottled_at": "stringOrNull (Year/Date)",
+ "batch_info": "stringOrNull",
+ "bottleCode": "stringOrNull",
+ "whiskybaseId": "stringOrNull",
"is_whisky": boolean,
- "confidence": number,
- "suggested_tags": ["tag1", "tag2"],
- "suggested_custom_tags": ["custom1"],
- "search_string": "site:whiskybase.com [Distillery] [Name] [Vintage/Age]"
+ "confidence": number
}
-
+`;
+
+export const getEnrichmentPrompt = (name: string, distillery: string, availableTags: string, language: string) => `
+TASK: You are a Whisky Sommelier.
+INPUT: A whisky named "${name}" from distillery "${distillery}".
+
+1. DATABASE LOOKUP:
+Retrieve the sensory profile and specific Whiskybase search string for this bottling.
+Use your expert knowledge.
+
+2. TAGGING:
+Select the top 5-8 flavor tags strictly from this list:
+[${availableTags}]
+
+3. SEARCH STRING:
+Create a precise search string for Whiskybase using: "site:whiskybase.com [Distillery] [Vintage/Age] [Bottler/Edition]"
+
+OUTPUT JSON:
+{
+ "suggested_tags": ["tag1", "tag2", "tag3"],
+ "suggested_custom_tags": ["uniquer_note_if_missing_in_list"],
+ "search_string": "string"
+}
+`;
+
+// Legacy support (to avoid immediate breaking changes while refactoring)
+export const getSystemPrompt = (availableTags: string, language: string) => `
+${getOcrPrompt()}
+Additionally, provide:
+- suggested_tags: string[] (matched against [${availableTags}])
+- suggested_custom_tags: string[]
+- search_string: string
`;
diff --git a/src/lib/db.ts b/src/lib/db.ts
index 39e02f6..169de1f 100644
--- a/src/lib/db.ts
+++ b/src/lib/db.ts
@@ -7,6 +7,10 @@ export interface PendingScan {
timestamp: number;
provider?: 'gemini' | 'mistral';
locale?: string;
+ metadata?: any; // Bottle metadata for offline scans
+ syncing?: number; // 0 or 1 for indexing
+ attempts?: number;
+ last_error?: string;
}
export interface PendingTasting {
@@ -25,6 +29,9 @@ export interface PendingTasting {
};
photo?: string;
tasted_at: string;
+ syncing?: number; // 0 or 1 for indexing
+ attempts?: number;
+ last_error?: string;
}
export interface CachedTag {
@@ -80,9 +87,9 @@ export class WhiskyDexie extends Dexie {
constructor() {
super('WhiskyVault');
- this.version(4).stores({
- pending_scans: '++id, temp_id, timestamp, locale',
- pending_tastings: '++id, bottle_id, pending_bottle_id, tasted_at',
+ this.version(6).stores({
+ pending_scans: '++id, temp_id, timestamp, locale, syncing, attempts',
+ pending_tastings: '++id, bottle_id, pending_bottle_id, tasted_at, syncing, attempts',
cache_tags: 'id, category, name',
cache_buddies: 'id, name',
cache_bottles: 'id, name, distillery',
diff --git a/src/lib/gemini.ts b/src/lib/gemini.ts
index 8a8515e..34f6d7e 100644
--- a/src/lib/gemini.ts
+++ b/src/lib/gemini.ts
@@ -5,7 +5,6 @@ const apiKey = process.env.GEMINI_API_KEY!;
const genAI = new GoogleGenerativeAI(apiKey);
export const geminiModel = genAI.getGenerativeModel({
- //model: 'gemini-3-flash-preview',
model: 'gemini-2.5-flash',
generationConfig: {
responseMimeType: 'application/json',
diff --git a/src/proxy.ts b/src/proxy.ts
index ee07741..11c1e39 100644
--- a/src/proxy.ts
+++ b/src/proxy.ts
@@ -33,13 +33,13 @@ export async function proxy(request: NextRequest) {
}
);
- const { data: { session } } = await supabase.auth.getSession();
+ const { data: { user } } = await supabase.auth.getUser();
const url = new URL(request.url);
const isStatic = url.pathname.startsWith('/_next') || url.pathname.includes('/icon-') || url.pathname === '/favicon.ico';
if (!isStatic) {
- const status = session ? `User:${session.user.id.slice(0, 8)}` : 'No Session';
+ const status = user ? `User:${user.id.slice(0, 8)}` : 'No Session';
console.log(`[Proxy] ${request.method} ${url.pathname} | ${status}`);
}
diff --git a/src/services/analyze-bottle-mistral.ts b/src/services/analyze-bottle-mistral.ts
index b018803..e76765e 100644
--- a/src/services/analyze-bottle-mistral.ts
+++ b/src/services/analyze-bottle-mistral.ts
@@ -36,12 +36,12 @@ export async function analyzeBottleMistral(input: any): Promise
0 ? tags.join(', ') : 'Keine Tags verfügbar', locale);
- const startApi = performance.now();
- const chatResponse = await client.chat.complete({
- model: 'mistral-large-latest',
- messages: [
- {
- role: 'user',
- content: [
- { type: 'text', text: prompt },
- { type: 'image_url', imageUrl: dataUrl }
- ]
- }
- ],
- responseFormat: { type: 'json_object' },
- temperature: 0.1
- });
- const endApi = performance.now();
-
- const startParse = performance.now();
- const rawContent = chatResponse.choices?.[0].message.content;
- if (!rawContent) throw new Error("Keine Antwort von Mistral");
-
- let jsonData;
try {
- jsonData = JSON.parse(rawContent as string);
- } catch (e) {
- const cleanedText = (rawContent as string).replace(/```json/g, '').replace(/```/g, '');
- jsonData = JSON.parse(cleanedText);
+ const startApi = performance.now();
+ const chatResponse = await client.chat.complete({
+ model: 'mistral-large-latest',
+ messages: [
+ {
+ role: 'user',
+ content: [
+ { type: 'text', text: prompt },
+ { type: 'image_url', imageUrl: dataUrl }
+ ]
+ }
+ ],
+ responseFormat: { type: 'json_object' },
+ temperature: 0.1
+ });
+ const endApi = performance.now();
+
+ const startParse = performance.now();
+ const rawContent = chatResponse.choices?.[0].message.content;
+ if (!rawContent) throw new Error("Keine Antwort von Mistral");
+
+ let jsonData;
+ try {
+ jsonData = JSON.parse(rawContent as string);
+ } catch (e) {
+ const cleanedText = (rawContent as string).replace(/```json/g, '').replace(/```/g, '');
+ jsonData = JSON.parse(cleanedText);
+ }
+
+ if (Array.isArray(jsonData)) jsonData = jsonData[0];
+ console.log('[Mistral AI] JSON Response:', jsonData);
+
+ const searchString = jsonData.search_string;
+ delete jsonData.search_string;
+
+ if (typeof jsonData.abv === 'string') {
+ jsonData.abv = parseFloat(jsonData.abv.replace('%', '').trim());
+ }
+
+ if (jsonData.age) jsonData.age = parseInt(jsonData.age);
+ if (jsonData.vintage) jsonData.vintage = parseInt(jsonData.vintage);
+
+ const validatedData = BottleMetadataSchema.parse(jsonData);
+ const endParse = performance.now();
+
+ await trackApiUsage({
+ userId: userId,
+ apiType: 'gemini_ai',
+ endpoint: 'mistral/mistral-large',
+ success: true
+ });
+
+ await deductCredits(userId, 'gemini_ai', 'Mistral AI analysis');
+
+ await supabase
+ .from('vision_cache')
+ .insert({ hash: imageHash, result: validatedData });
+
+ return {
+ success: true,
+ data: validatedData,
+ search_string: searchString,
+ perf: {
+ apiDuration: endApi - startApi,
+ parseDuration: endParse - startParse,
+ uploadSize: uploadSize
+ },
+ raw: jsonData
+ };
+
+ } catch (aiError: any) {
+ console.warn('[MistralAnalysis] AI Analysis failed, providing fallback path:', aiError.message);
+
+ await trackApiUsage({
+ userId: userId,
+ apiType: 'gemini_ai',
+ endpoint: 'mistral/mistral-large',
+ success: false,
+ errorMessage: aiError.message
+ });
+
+ return {
+ success: false,
+ isAiError: true,
+ error: aiError.message,
+ imageHash: imageHash
+ } as any;
}
- if (Array.isArray(jsonData)) jsonData = jsonData[0];
- console.log('[Mistral AI] JSON Response:', jsonData);
-
- // Extract search_string before validation
- const searchString = jsonData.search_string;
- delete jsonData.search_string;
-
- // Ensure abv is a number if it came as a string
- if (typeof jsonData.abv === 'string') {
- jsonData.abv = parseFloat(jsonData.abv.replace('%', '').trim());
- }
-
- // Ensure age/vintage are numbers
- if (jsonData.age) jsonData.age = parseInt(jsonData.age);
- if (jsonData.vintage) jsonData.vintage = parseInt(jsonData.vintage);
-
- const validatedData = BottleMetadataSchema.parse(jsonData);
- const endParse = performance.now();
-
- // Track usage
- await trackApiUsage({
- userId: userId,
- apiType: 'gemini_ai',
- endpoint: 'mistral/mistral-large',
- success: true
- });
-
- // Deduct credits
- await deductCredits(userId, 'gemini_ai', 'Mistral AI analysis');
-
- // Store in Cache
- await supabase
- .from('vision_cache')
- .insert({ hash: imageHash, result: validatedData });
-
- return {
- success: true,
- data: validatedData,
- search_string: searchString,
- perf: {
- apiDuration: endApi - startApi,
- parseDuration: endParse - startParse,
- uploadSize: uploadSize
- },
- raw: jsonData
- };
-
} catch (error) {
- console.error('Mistral Analysis Error:', error);
-
- if (supabase) {
- const { data: { session } } = await supabase.auth.getSession();
- if (session?.user) {
- await trackApiUsage({
- userId: session.user.id,
- apiType: 'gemini_ai',
- endpoint: 'mistral/mistral-large',
- success: false,
- errorMessage: error instanceof Error ? error.message : 'Unknown error'
- });
- }
- }
-
+ console.error('Mistral Analysis Global Error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Mistral AI analysis failed.',
diff --git a/src/services/analyze-bottle.ts b/src/services/analyze-bottle.ts
index 4116bd1..432efd6 100644
--- a/src/services/analyze-bottle.ts
+++ b/src/services/analyze-bottle.ts
@@ -37,13 +37,13 @@ export async function analyzeBottle(input: any): Promise {
// 2. Auth & Credits (bleibt gleich)
supabase = await createClient();
- const { data: { session } } = await supabase.auth.getSession();
+ const { data: { user } } = await supabase.auth.getUser();
- if (!session || !session.user) {
+ if (!user) {
return { success: false, error: 'Nicht autorisiert oder Session abgelaufen.' };
}
- const userId = session.user.id;
+ const userId = user.id;
const creditCheck = await checkCreditBalance(userId, 'gemini_ai');
if (!creditCheck.allowed) {
@@ -80,96 +80,96 @@ export async function analyzeBottle(input: any): Promise {
}
// 5. Für Gemini vorbereiten
- // Wir müssen es hier zwar zu Base64 machen, aber Node.js (C++) macht das
- // extrem effizient. Das Problem vorher war der JSON Parser von Next.js.
const base64Data = buffer.toString('base64');
- const mimeType = file.type || 'image/webp'; // Fallback
+ const mimeType = file.type || 'image/webp';
const uploadSize = buffer.length;
const instruction = getSystemPrompt(tags.length > 0 ? tags.join(', ') : 'No tags available', locale);
- // API Call
- const startApi = performance.now();
- const result = await geminiModel.generateContent([
- {
- inlineData: {
- data: base64Data,
- mimeType: mimeType,
- },
- },
- { text: instruction },
- ]);
- const endApi = performance.now();
-
- const startParse = performance.now();
- const responseText = result.response.text();
-
- // JSON Parsing der ANTWORT (das ist klein, das schafft der N100 locker)
- let jsonData;
try {
- jsonData = JSON.parse(responseText);
- } catch (e) {
- // Fallback falls Gemini Markdown ```json Blöcke schickt
- const cleanedText = responseText.replace(/```json/g, '').replace(/```/g, '');
- jsonData = JSON.parse(cleanedText);
+ // API Call
+ const startApi = performance.now();
+ const result = await geminiModel.generateContent([
+ {
+ inlineData: {
+ data: base64Data,
+ mimeType: mimeType,
+ },
+ },
+ { text: instruction },
+ ]);
+ const endApi = performance.now();
+
+ const startParse = performance.now();
+ const responseText = result.response.text();
+
+ let jsonData;
+ try {
+ jsonData = JSON.parse(responseText);
+ } catch (e) {
+ const cleanedText = responseText.replace(/```json/g, '').replace(/```/g, '');
+ jsonData = JSON.parse(cleanedText);
+ }
+
+ if (Array.isArray(jsonData)) jsonData = jsonData[0];
+ console.log('[Gemini AI] JSON Response:', jsonData);
+
+ const searchString = jsonData.search_string;
+ delete jsonData.search_string;
+
+ if (!jsonData) throw new Error('Keine Daten in der KI-Antwort gefunden.');
+
+ const validatedData = BottleMetadataSchema.parse(jsonData);
+ const endParse = performance.now();
+
+ // 6. Tracking & Credits
+ await trackApiUsage({
+ userId: userId,
+ apiType: 'gemini_ai',
+ endpoint: 'generateContent',
+ success: true
+ });
+
+ await deductCredits(userId, 'gemini_ai', 'Bottle analysis');
+
+ // Cache speichern
+ await supabase
+ .from('vision_cache')
+ .insert({ hash: imageHash, result: validatedData });
+
+ return {
+ success: true,
+ data: validatedData,
+ search_string: searchString,
+ perf: {
+ apiDuration: endApi - startApi,
+ parseDuration: endParse - startParse,
+ uploadSize: uploadSize
+ },
+ raw: jsonData
+ } as any;
+
+ } catch (aiError: any) {
+ console.warn('[AnalyzeBottle] AI Analysis failed, providing fallback path:', aiError.message);
+
+ await trackApiUsage({
+ userId: userId,
+ apiType: 'gemini_ai',
+ endpoint: 'generateContent',
+ success: false,
+ errorMessage: aiError.message
+ });
+
+ return {
+ success: false,
+ isAiError: true,
+ error: aiError.message,
+ imageHash: imageHash
+ } as any;
}
- if (Array.isArray(jsonData)) jsonData = jsonData[0];
- console.log('[Gemini AI] JSON Response:', jsonData);
-
- const searchString = jsonData.search_string;
- delete jsonData.search_string;
-
- if (!jsonData) throw new Error('Keine Daten in der KI-Antwort gefunden.');
-
- const validatedData = BottleMetadataSchema.parse(jsonData);
- const endParse = performance.now();
-
- // 6. Tracking & Credits (bleibt gleich)
- await trackApiUsage({
- userId: userId,
- apiType: 'gemini_ai',
- endpoint: 'generateContent',
- success: true
- });
-
- await deductCredits(userId, 'gemini_ai', 'Bottle analysis');
-
- // Cache speichern
- const { error: storeError } = await supabase
- .from('vision_cache')
- .insert({ hash: imageHash, result: validatedData });
-
- if (storeError) console.warn(`[AI Cache] Storage failed: ${storeError.message}`);
-
- return {
- success: true,
- data: validatedData,
- search_string: searchString,
- perf: {
- apiDuration: endApi - startApi,
- parseDuration: endParse - startParse,
- uploadSize: uploadSize
- },
- raw: jsonData
- } as any;
-
} catch (error) {
- console.error('Gemini Analysis Error:', error);
- // Error Tracking Logic (bleibt gleich)
- if (supabase) {
- const { data: { session } } = await supabase.auth.getSession();
- if (session?.user) {
- await trackApiUsage({
- userId: session.user.id,
- apiType: 'gemini_ai',
- endpoint: 'generateContent',
- success: false,
- errorMessage: error instanceof Error ? error.message : 'Unknown error'
- });
- }
- }
-
+ console.error('Gemini Analysis Global Error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'An unknown error occurred during analysis.',
diff --git a/src/services/buddy.ts b/src/services/buddy.ts
index 47f042d..ed34d2b 100644
--- a/src/services/buddy.ts
+++ b/src/services/buddy.ts
@@ -8,12 +8,12 @@ export async function addBuddy(rawData: BuddyData) {
try {
const { name } = BuddySchema.parse(rawData);
- const { data: { session } } = await supabase.auth.getSession();
- if (!session) throw new Error('Nicht autorisiert');
+ const { data: { user } } = await supabase.auth.getUser();
+ if (!user) throw new Error('Nicht autorisiert');
const { data, error } = await supabase
.from('buddies')
- .insert([{ name, user_id: session.user.id }])
+ .insert([{ name, user_id: user.id }])
.select()
.single();
@@ -32,14 +32,14 @@ export async function deleteBuddy(id: string) {
const supabase = await createClient();
try {
- const { data: { session } } = await supabase.auth.getSession();
- if (!session) throw new Error('Nicht autorisiert');
+ const { data: { user } } = await supabase.auth.getUser();
+ if (!user) throw new Error('Nicht autorisiert');
const { error } = await supabase
.from('buddies')
.delete()
.eq('id', id)
- .eq('user_id', session.user.id);
+ .eq('user_id', user.id);
if (error) throw error;
return { success: true };
diff --git a/src/services/delete-bottle.ts b/src/services/delete-bottle.ts
index e18c842..d74a300 100644
--- a/src/services/delete-bottle.ts
+++ b/src/services/delete-bottle.ts
@@ -7,8 +7,8 @@ export async function deleteBottle(bottleId: string) {
const supabase = await createClient();
try {
- const { data: { session } } = await supabase.auth.getSession();
- if (!session) {
+ const { data: { user } } = await supabase.auth.getUser();
+ if (!user) {
throw new Error('Nicht autorisiert.');
}
@@ -23,7 +23,7 @@ export async function deleteBottle(bottleId: string) {
throw new Error('Flasche nicht gefunden.');
}
- if (bottle.user_id !== session.user.id) {
+ if (bottle.user_id !== user.id) {
throw new Error('Keine Berechtigung.');
}
diff --git a/src/services/delete-session.ts b/src/services/delete-session.ts
index 0e8945f..0f94882 100644
--- a/src/services/delete-session.ts
+++ b/src/services/delete-session.ts
@@ -7,8 +7,8 @@ export async function deleteSession(sessionId: string) {
const supabase = await createClient();
try {
- const { data: { session } } = await supabase.auth.getSession();
- if (!session) {
+ const { data: { user } } = await supabase.auth.getUser();
+ if (!user) {
throw new Error('Nicht autorisiert.');
}
@@ -16,7 +16,7 @@ export async function deleteSession(sessionId: string) {
.from('tasting_sessions')
.delete()
.eq('id', sessionId)
- .eq('user_id', session.user.id);
+ .eq('user_id', user.id);
if (deleteError) throw deleteError;
diff --git a/src/services/delete-tasting.ts b/src/services/delete-tasting.ts
index e0ad4ab..48fb0f4 100644
--- a/src/services/delete-tasting.ts
+++ b/src/services/delete-tasting.ts
@@ -7,8 +7,8 @@ export async function deleteTasting(tastingId: string, bottleId: string) {
const supabase = await createClient();
try {
- const { data: { session } } = await supabase.auth.getSession();
- if (!session) {
+ const { data: { user } } = await supabase.auth.getUser();
+ if (!user) {
throw new Error('Nicht autorisiert.');
}
@@ -16,7 +16,7 @@ export async function deleteTasting(tastingId: string, bottleId: string) {
.from('tastings')
.delete()
.eq('id', tastingId)
- .eq('user_id', session.user.id);
+ .eq('user_id', user.id);
if (deleteError) throw deleteError;
diff --git a/src/services/find-matching-bottle.ts b/src/services/find-matching-bottle.ts
index d70bb54..d64ad5e 100644
--- a/src/services/find-matching-bottle.ts
+++ b/src/services/find-matching-bottle.ts
@@ -7,10 +7,10 @@ export async function findMatchingBottle(metadata: BottleMetadata) {
const supabase = await createClient();
try {
- const { data: { session } } = await supabase.auth.getSession();
- if (!session) return null;
+ const { data: { user } } = await supabase.auth.getUser();
+ if (!user) return null;
- const userId = session.user.id;
+ const userId = user.id;
// 1. Try matching by Whiskybase ID (most reliable)
if (metadata.whiskybaseId) {
diff --git a/src/services/save-bottle.ts b/src/services/save-bottle.ts
index 72165b6..211d1f4 100644
--- a/src/services/save-bottle.ts
+++ b/src/services/save-bottle.ts
@@ -14,12 +14,12 @@ export async function saveBottle(
try {
const metadata = BottleMetadataSchema.parse(rawMetadata);
- const { data: { session } } = await supabase.auth.getSession();
- if (!session) {
+ const { data: { user } } = await supabase.auth.getUser();
+ if (!user) {
throw new Error('Nicht autorisiert oder Session abgelaufen.');
}
- const userId = session.user.id;
+ const userId = user.id;
let finalImageUrl = preUploadedUrl;
// 1. Upload Image to Storage if not already uploaded
@@ -50,6 +50,26 @@ export async function saveBottle(
throw new Error('Kein Bild zum Speichern vorhanden.');
}
+ // 1.5 Deduplication Check
+ // If a bottle with the same name/distillery was created by the same user in the last 5 minutes,
+ // we treat it as a duplicate (likely from a race condition or double sync).
+ const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString();
+ const { data: existingBottle } = await supabase
+ .from('bottles')
+ .select('*')
+ .eq('user_id', userId)
+ .eq('name', metadata.name)
+ .eq('distillery', metadata.distillery)
+ .gte('created_at', fiveMinutesAgo)
+ .order('created_at', { ascending: false })
+ .limit(1)
+ .maybeSingle();
+
+ if (existingBottle) {
+ console.log('[saveBottle] Potential duplicate detected, returning existing bottle:', existingBottle.id);
+ return { success: true, data: existingBottle };
+ }
+
// 2. Save Metadata to Database
const { data: bottleData, error: dbError } = await supabase
.from('bottles')
@@ -64,7 +84,7 @@ export async function saveBottle(
image_url: finalImageUrl,
status: 'sealed',
is_whisky: metadata.is_whisky ?? true,
- confidence: metadata.confidence ?? 100,
+ confidence: metadata.confidence ? Math.round(metadata.confidence * 100) : 100,
distilled_at: metadata.distilled_at,
bottled_at: metadata.bottled_at,
batch_info: metadata.batch_info,
diff --git a/src/services/save-tasting.ts b/src/services/save-tasting.ts
index 8caf063..e7f1dea 100644
--- a/src/services/save-tasting.ts
+++ b/src/services/save-tasting.ts
@@ -11,8 +11,8 @@ export async function saveTasting(rawData: TastingNoteData) {
try {
const data = TastingNoteSchema.parse(rawData);
- const { data: { session } } = await supabase.auth.getSession();
- if (!session) throw new Error('Nicht autorisiert');
+ const { data: { user } } = await supabase.auth.getUser();
+ if (!user) throw new Error('Nicht autorisiert');
// Validate Session Age (12 hour limit)
if (data.session_id) {
@@ -26,7 +26,7 @@ export async function saveTasting(rawData: TastingNoteData) {
.from('tastings')
.insert({
bottle_id: data.bottle_id,
- user_id: session.user.id,
+ user_id: user.id,
session_id: data.session_id,
rating: data.rating,
nose_notes: data.nose_notes,
@@ -46,7 +46,7 @@ export async function saveTasting(rawData: TastingNoteData) {
const buddies = data.buddy_ids.map(buddyId => ({
tasting_id: tasting.id,
buddy_id: buddyId,
- user_id: session.user.id
+ user_id: user.id
}));
const { error: tagError } = await supabase
.from('tasting_buddies')
@@ -64,7 +64,7 @@ export async function saveTasting(rawData: TastingNoteData) {
const aromaTags = data.tag_ids.map(tagId => ({
tasting_id: tasting.id,
tag_id: tagId,
- user_id: session.user.id
+ user_id: user.id
}));
const { error: aromaTagError } = await supabase
.from('tasting_tags')
diff --git a/src/services/tags.ts b/src/services/tags.ts
index 70ad8c5..78e4eb4 100644
--- a/src/services/tags.ts
+++ b/src/services/tags.ts
@@ -74,8 +74,8 @@ export async function createCustomTag(rawName: string, rawCategory: TagCategory)
try {
const { name, category } = TagSchema.parse({ name: rawName, category: rawCategory });
- const { data: { session } } = await supabase.auth.getSession();
- if (!session) throw new Error('Nicht autorisiert');
+ const { data: { user } } = await supabase.auth.getUser();
+ if (!user) throw new Error('Nicht autorisiert');
const { data, error } = await supabase
.from('tags')
@@ -83,7 +83,7 @@ export async function createCustomTag(rawName: string, rawCategory: TagCategory)
name,
category,
is_system_default: false,
- created_by: session.user.id
+ created_by: user.id
})
.select()
.single();
diff --git a/src/services/update-bottle-status.ts b/src/services/update-bottle-status.ts
index a9799e0..dd085cb 100644
--- a/src/services/update-bottle-status.ts
+++ b/src/services/update-bottle-status.ts
@@ -7,8 +7,8 @@ export async function updateBottleStatus(bottleId: string, status: 'sealed' | 'o
const supabase = await createClient();
try {
- const { data: { session } } = await supabase.auth.getSession();
- if (!session) {
+ const { data: { user } } = await supabase.auth.getUser();
+ if (!user) {
throw new Error('Nicht autorisiert');
}
@@ -20,7 +20,7 @@ export async function updateBottleStatus(bottleId: string, status: 'sealed' | 'o
finished_at: status === 'empty' ? new Date().toISOString() : null
})
.eq('id', bottleId)
- .eq('user_id', session.user.id);
+ .eq('user_id', user.id);
if (error) {
throw error;
diff --git a/src/services/update-bottle.ts b/src/services/update-bottle.ts
index 5c4b52c..69fc382 100644
--- a/src/services/update-bottle.ts
+++ b/src/services/update-bottle.ts
@@ -10,8 +10,8 @@ export async function updateBottle(bottleId: string, rawData: UpdateBottleData)
try {
const data = UpdateBottleSchema.parse(rawData);
- const { data: { session } } = await supabase.auth.getSession();
- if (!session) throw new Error('Nicht autorisiert');
+ const { data: { user } } = await supabase.auth.getUser();
+ if (!user) throw new Error('Nicht autorisiert');
const { error } = await supabase
.from('bottles')
@@ -29,7 +29,7 @@ export async function updateBottle(bottleId: string, rawData: UpdateBottleData)
updated_at: new Date().toISOString(),
})
.eq('id', bottleId)
- .eq('user_id', session.user.id);
+ .eq('user_id', user.id);
if (error) throw error;
diff --git a/src/types/whisky.ts b/src/types/whisky.ts
index 060ec15..053d298 100644
--- a/src/types/whisky.ts
+++ b/src/types/whisky.ts
@@ -3,6 +3,7 @@ import { z } from 'zod';
export const BottleMetadataSchema = z.object({
name: z.string().trim().min(1).max(255).nullish(),
distillery: z.string().trim().max(255).nullish(),
+ bottler: z.string().trim().max(255).nullish(),
category: z.string().trim().max(100).nullish(),
abv: z.number().min(0).max(100).nullish(),
age: z.number().min(0).max(100).nullish(),
@@ -76,12 +77,12 @@ export type AdminSettingsData = z.infer;
export const DiscoveryDataSchema = z.object({
name: z.string().trim().min(1).max(255),
- distillery: z.string().trim().max(255).optional(),
- abv: z.number().min(0).max(100).optional(),
- age: z.number().min(0).max(100).optional(),
- distilled_at: z.string().trim().max(50).optional(),
- bottled_at: z.string().trim().max(50).optional(),
- batch_info: z.string().trim().max(255).optional(),
+ distillery: z.string().trim().max(255).nullish(),
+ abv: z.number().min(0).max(100).nullish(),
+ age: z.number().min(0).max(100).nullish(),
+ distilled_at: z.string().trim().max(50).nullish(),
+ bottled_at: z.string().trim().max(50).nullish(),
+ batch_info: z.string().trim().max(255).nullish(),
});
export type DiscoveryData = z.infer;
@@ -96,10 +97,25 @@ export interface AnalysisResponse {
success: boolean;
data?: BottleMetadata;
error?: string;
+ isAiError?: boolean;
+ imageHash?: string;
perf?: {
- apiDuration: number;
- parseDuration: number;
+ // Legacy fields (kept for backward compatibility)
+ apiDuration?: number;
+ parseDuration?: number;
+
+ // Detailed metrics
+ imagePrep?: number;
+ cacheCheck?: number;
+ encoding?: number;
+ modelInit?: number;
+ apiCall?: number;
+ parsing?: number;
+ validation?: number;
+ dbOps?: number;
uploadSize: number;
+ total?: number;
+ cacheHit?: boolean;
};
raw?: any;
}
diff --git a/src/utils/generate-dummy-metadata.ts b/src/utils/generate-dummy-metadata.ts
new file mode 100644
index 0000000..4137ea5
--- /dev/null
+++ b/src/utils/generate-dummy-metadata.ts
@@ -0,0 +1,24 @@
+import { BottleMetadata } from '@/types/whisky';
+
+/**
+ * Generate placeholder metadata for offline scans.
+ * Returns editable dummy data that user can fill in manually.
+ */
+export function generateDummyMetadata(imageFile: File): BottleMetadata {
+ return {
+ name: '', // Empty - user must fill in
+ distillery: '', // Empty - user must fill in
+ category: 'Whisky',
+ is_whisky: true,
+ confidence: 0,
+ abv: null,
+ age: null,
+ vintage: null,
+ bottler: null,
+ batch_info: null,
+ bottleCode: null,
+ distilled_at: null,
+ bottled_at: null,
+ whiskybaseId: null,
+ };
+}
diff --git a/src/utils/image-processing.ts b/src/utils/image-processing.ts
index e10c3b4..14b31a2 100644
--- a/src/utils/image-processing.ts
+++ b/src/utils/image-processing.ts
@@ -47,7 +47,8 @@ export async function processImageForAI(file: File): Promise {
maxSizeMB: 0.4,
maxWidthOrHeight: 1024,
useWebWorker: true,
- fileType: 'image/webp'
+ fileType: 'image/webp',
+ libURL: '/lib/browser-image-compression.js'
};
try {