diff --git a/.offline b/.offline new file mode 100644 index 0000000..98d1b38 --- /dev/null +++ b/.offline @@ -0,0 +1,104 @@ +Code-Snippet + +sequenceDiagram + autonumber + participant User + participant UI as Next.js UI (Optimistic) + participant IDB as Local DB (Dexie/IndexedDB) + participant Sync as Sync Worker / Service + participant SupabaseStorage as Supabase Storage (Images) + participant SupabaseDB as Supabase Database (Tables) + + box rgb(33, 37, 41) Offline Phase (Messekeller) + Note over User, IDB: User hat kein Internet + User->>UI: Macht Foto & trägt Whisky ein + UI->>UI: Konvertiert Foto zu Blob/Base64 + UI->>IDB: Speichert "Draft" (Blob + Daten + tasted_at) + UI->>UI: Generiert lokale Blob-URL + UI-->>User: Zeigt Eintrag sofort an (Optimistic UI) + Note right of UI: UI zeigt Status "Wartet auf Sync..." + end + + box rgb(13, 17, 23) Online / Sync Phase + Sync->>Sync: Event: 'online' detected + Sync->>IDB: Hole alle Items aus 'sync_queue' + + loop Für jeden Eintrag in Queue + alt Hat Bild? + Sync->>SupabaseStorage: Upload Blob (Bucket: 'tastings') + activate SupabaseStorage + SupabaseStorage-->>Sync: Return Public Public URL + deactivate SupabaseStorage + else Kein Bild + Sync->>Sync: Nutze Placeholder URL / Null + end + + Sync->>SupabaseDB: INSERT row (mit neuer Image URL & tasted_at) + activate SupabaseDB + SupabaseDB-->>Sync: Success (New ID) + deactivate SupabaseDB + + Sync->>IDB: Lösche Eintrag aus 'sync_queue' + Sync->>UI: Trigger Re-Fetch / Update State + UI-->>User: Status ändert sich zu "Gespeichert ✔" + end + end + +Der Prompt für das LLM (Copy & Paste) + +Wenn du das nun programmieren lassen willst, kopiere alles zwischen den Trennlinien und gib es dem LLM. Es enthält das Diagramm und die technischen Instruktionen. + +START PROMPT + +Ich entwickle eine Offline-First Whisky Tasting App mit Next.js, Supabase und Dexie.js (IndexedDB). Bitte implementiere die Logik für den Datensync basierend auf dem folgenden Architektur-Diagramm. + +Technologie-Stack: + + Frontend: Next.js (React) + + Local DB: Dexie.js (für IndexedDB Wrapper) + + Remote DB: Supabase (PostgreSQL + Storage) + + State: React Query (TanStack Query) + +Wichtige Anforderungen: + + Zwei Zeitstempel: Beachte den Unterschied zwischen created_at (wann es in Supabase ankommt) und tasted_at (wann der User den Button gedrückt hat). tasted_at muss lokal erfasst und hochgeladen werden. + + Image Handling: Bilder werden lokal als Blob gespeichert. Beim Sync muss ZUERST das Bild in den Supabase Storage hochgeladen werden, um den public_url String zu erhalten. ERST DANN darf der Datenbank-Eintrag (Insert) erfolgen. + + Optimistic UI: Die UI muss Daten aus Dexie (useLiveQuery von Dexie oder ähnlich) und Supabase gemischt anzeigen, damit der User keinen Unterschied merkt. + +Hier ist das Ablaufdiagramm (Mermaid): +Code-Snippet + +sequenceDiagram + participant User + participant UI as Next.js UI + participant IDB as Local DB (Dexie) + participant Sync as Sync Function + participant Supabase as Supabase Cloud + + Note over User, IDB: Offline Action + User->>UI: Save Dram (Photo + Notes) + UI->>IDB: Add to table 'tasting_queue' (Blob, Data, tasted_at) + UI-->>User: Show entry immediately (via Blob URL) + + Note over Sync, Supabase: Sync Process (Background) + Sync->>IDB: Check for items in 'tasting_queue' + loop For each Item + Sync->>Supabase: Upload Image Blob -> Get URL + Sync->>Supabase: Insert Database Row (with Image URL) + Sync->>IDB: Delete from 'tasting_queue' + end + +Bitte erstelle mir: + + Das Dexie Schema (db.ts). + + Die Sync-Funktion, die prüft ob wir online sind und die Queue abarbeitet. + + Einen React Hook, der diesen Sync automatisch triggert, sobald die Verbindung wieder da ist. + +END PROMPT \ No newline at end of file diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 1fba40e..9824f6f 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -9,6 +9,7 @@ import { SessionProvider } from "@/context/SessionContext"; import ActiveSessionBanner from "@/components/ActiveSessionBanner"; import MainContentWrapper from "@/components/MainContentWrapper"; import AuthListener from "@/components/AuthListener"; +import SyncHandler from "@/components/SyncHandler"; const inter = Inter({ subsets: ["latin"] }); @@ -50,6 +51,7 @@ export default function RootLayout({ + diff --git a/src/components/CameraCapture.tsx b/src/components/CameraCapture.tsx index ee4b70d..e6673e8 100644 --- a/src/components/CameraCapture.tsx +++ b/src/components/CameraCapture.tsx @@ -8,7 +8,7 @@ import { useRouter, useSearchParams } from 'next/navigation'; import { analyzeBottle } from '@/services/analyze-bottle'; import { saveBottle } from '@/services/save-bottle'; import { BottleMetadata } from '@/types/whisky'; -import { savePendingBottle } from '@/lib/offline-db'; +import { db } from '@/lib/db'; import { v4 as uuidv4 } from 'uuid'; import { findMatchingBottle } from '@/services/find-matching-bottle'; import { validateSession } from '@/services/validate-session'; @@ -125,10 +125,10 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS // Check if Offline if (!navigator.onLine) { console.log('Offline detected. Queuing image...'); - await savePendingBottle({ - id: uuidv4(), + await db.pending_scans.add({ imageBase64: compressedBase64, timestamp: Date.now(), + provider: aiProvider }); setIsQueued(true); return; diff --git a/src/components/SyncHandler.tsx b/src/components/SyncHandler.tsx new file mode 100644 index 0000000..53b9b97 --- /dev/null +++ b/src/components/SyncHandler.tsx @@ -0,0 +1,8 @@ +'use client'; + +import { useCacheSync } from '@/hooks/useCacheSync'; + +export default function SyncHandler() { + useCacheSync(); + return null; +} diff --git a/src/components/TagSelector.tsx b/src/components/TagSelector.tsx index d9c7115..1db5fa4 100644 --- a/src/components/TagSelector.tsx +++ b/src/components/TagSelector.tsx @@ -1,9 +1,11 @@ 'use client'; -import React, { useState, useEffect, useMemo } from 'react'; -import { Tag, TagCategory, getTagsByCategory, createCustomTag } from '@/services/tags'; +import React, { useState, useMemo } from 'react'; +import { TagCategory, createCustomTag } from '@/services/tags'; import { X, Plus, Search, Check, Loader2, Sparkles } from 'lucide-react'; import { useI18n } from '@/i18n/I18nContext'; +import { useLiveQuery } from 'dexie-react-hooks'; +import { db } from '@/lib/db'; interface TagSelectorProps { category: TagCategory; @@ -16,26 +18,23 @@ interface TagSelectorProps { export default function TagSelector({ category, selectedTagIds, onToggleTag, label, suggestedTagNames, suggestedCustomTagNames }: TagSelectorProps) { const { t } = useI18n(); - const [tags, setTags] = useState([]); const [search, setSearch] = useState(''); - const [isLoading, setIsLoading] = useState(true); const [isCreating, setIsCreating] = useState(false); const [creatingSuggestion, setCreatingSuggestion] = useState(null); - useEffect(() => { - const fetchTags = async () => { - setIsLoading(true); - const data = await getTagsByCategory(category); - setTags(data); - setIsLoading(false); - }; - fetchTags(); - }, [category]); + const tags = useLiveQuery( + () => db.cache_tags.where('category').equals(category).sortBy('popularity_score'), + [category], + [] + ); + + const isLoading = tags === undefined; const filteredTags = useMemo(() => { - if (!search) return tags; + const tagList = tags || []; + if (!search) return tagList; const s = search.toLowerCase(); - return tags.filter(tag => { + return tagList.filter((tag: any) => { const rawMatch = tag.name.toLowerCase().includes(s); const translatedMatch = tag.is_system_default && t(`aroma.${tag.name}`).toLowerCase().includes(s); return rawMatch || translatedMatch; diff --git a/src/components/TastingList.tsx b/src/components/TastingList.tsx index 918d79f..8c64bbe 100644 --- a/src/components/TastingList.tsx +++ b/src/components/TastingList.tsx @@ -6,6 +6,8 @@ import Link from 'next/link'; import AvatarStack from './AvatarStack'; import { useI18n } from '@/i18n/I18nContext'; import { deleteTasting } from '@/services/delete-tasting'; +import { useLiveQuery } from 'dexie-react-hooks'; +import { db } from '@/lib/db'; interface Tasting { id: string; @@ -40,14 +42,16 @@ interface Tasting { interface TastingListProps { initialTastings: Tasting[]; currentUserId?: string; + bottleId?: string; } -export default function TastingList({ initialTastings, currentUserId }: TastingListProps) { +export default function TastingList({ initialTastings, currentUserId, bottleId }: TastingListProps) { const { t } = useI18n(); const [sortBy, setSortBy] = useState<'date-desc' | 'date-asc' | 'rating-desc' | 'rating-asc'>('date-desc'); const [isDeleting, setIsDeleting] = useState(null); const handleDelete = async (tastingId: string, bottleId: string) => { + if (tastingId.startsWith('pending-')) return; if (!confirm('Bist du sicher, dass du diese Notiz löschen möchtest?')) return; setIsDeleting(tastingId); @@ -63,23 +67,43 @@ export default function TastingList({ initialTastings, currentUserId }: TastingL } }; + const pendingTastings = useLiveQuery( + () => bottleId + ? db.pending_tastings.where('bottle_id').equals(bottleId).toArray() + : db.pending_tastings.toArray(), + [bottleId], + [] + ); + const sortedTastings = useMemo(() => { - const result = [...initialTastings]; - return result.sort((a, b) => { + const merged = [ + ...initialTastings, + ...(pendingTastings || []).map(p => ({ + id: `pending-${p.id}`, + rating: p.data.rating, + nose_notes: p.data.nose_notes, + palate_notes: p.data.palate_notes, + finish_notes: p.data.finish_notes, + is_sample: p.data.is_sample, + bottle_id: p.bottle_id, + created_at: p.tasted_at, + user_id: currentUserId || '', + isPending: true + })) + ]; + + return merged.sort((a, b) => { + const timeA = new Date(a.created_at).getTime(); + const timeB = new Date(b.created_at).getTime(); switch (sortBy) { - case 'date-desc': - return new Date(b.created_at).getTime() - new Date(a.created_at).getTime(); - case 'date-asc': - return new Date(a.created_at).getTime() - new Date(b.created_at).getTime(); - case 'rating-desc': - return b.rating - a.rating; - case 'rating-asc': - return a.rating - b.rating; - default: - return 0; + case 'date-desc': return timeB - timeA; + case 'date-asc': return timeA - timeB; + case 'rating-desc': return b.rating - a.rating; + case 'rating-asc': return a.rating - b.rating; + default: return 0; } }); - }, [initialTastings, sortBy]); + }, [initialTastings, pendingTastings, sortBy, currentUserId]); if (!initialTastings || initialTastings.length === 0) { return ( @@ -125,6 +149,12 @@ export default function TastingList({ initialTastings, currentUserId }: TastingL }`}> {note.is_sample ? 'Sample' : 'Bottle'} + {(note as any).isPending && ( +
+ + Wartet auf Sync... +
+ )}
{new Date(note.created_at).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })} @@ -144,7 +174,7 @@ export default function TastingList({ initialTastings, currentUserId }: TastingL {new Date(note.created_at).toLocaleDateString('de-DE')}
- {(!currentUserId || note.user_id === currentUserId) && ( + {(!currentUserId || note.user_id === currentUserId) && !(note as any).isPending && ( )} diff --git a/src/hooks/useCacheSync.ts b/src/hooks/useCacheSync.ts new file mode 100644 index 0000000..d17277e --- /dev/null +++ b/src/hooks/useCacheSync.ts @@ -0,0 +1,44 @@ +import { useEffect } from 'react'; +import { createClientComponentClient } from '@supabase/auth-helpers-nextjs'; +import { db } from '@/lib/db'; +import { getAllSystemTags } from '@/services/tags'; + +export function useCacheSync() { + const supabase = createClientComponentClient(); + + useEffect(() => { + const syncCache = async () => { + if (!navigator.onLine) return; + + try { + // 1. Sync Tags + const tags = await getAllSystemTags(); + if (tags.length > 0) { + await db.cache_tags.bulkPut(tags.map(t => ({ + id: t.id, + name: t.name, + category: t.category, + is_system_default: t.is_system_default, + popularity_score: t.popularity_score || 0 + }))); + } + + // 2. Sync Buddies + const { data: buddies } = await supabase + .from('buddies') + .select('id, name') + .order('name'); + + if (buddies && buddies.length > 0) { + await db.cache_buddies.bulkPut(buddies); + } + } catch (err) { + console.error('Failed to sync offline cache:', err); + } + }; + + syncCache(); + window.addEventListener('online', syncCache); + return () => window.removeEventListener('online', syncCache); + }, [supabase]); +} diff --git a/src/lib/db.ts b/src/lib/db.ts new file mode 100644 index 0000000..90c8de6 --- /dev/null +++ b/src/lib/db.ts @@ -0,0 +1,57 @@ +import Dexie, { type Table } from 'dexie'; + +export interface PendingScan { + id?: number; + imageBase64: string; + timestamp: number; + provider?: 'gemini' | 'nebius'; +} + +export interface PendingTasting { + id?: number; + bottle_id: string; + data: { + session_id?: string; + rating: number; + nose_notes?: string; + palate_notes?: string; + finish_notes?: string; + is_sample?: boolean; + buddy_ids?: string[]; + tag_ids?: string[]; + }; + photo?: string; // Optional photo if taken during tasting + tasted_at: string; +} + +export interface CachedTag { + id: string; + name: string; + category: string; + is_system_default: boolean; + popularity_score: number; +} + +export interface CachedBuddy { + id: string; + name: string; +} + +export class WhiskyDexie extends Dexie { + pending_scans!: Table; + pending_tastings!: Table; + cache_tags!: Table; + cache_buddies!: Table; + + constructor() { + super('WhiskyVault'); + this.version(1).stores({ + pending_scans: '++id, timestamp', + pending_tastings: '++id, bottle_id, tasted_at', + cache_tags: 'id, category, name', + cache_buddies: 'id, name' + }); + } +} + +export const db = new WhiskyDexie(); diff --git a/src/lib/offline-db.ts b/src/lib/offline-db.ts deleted file mode 100644 index f80516c..0000000 --- a/src/lib/offline-db.ts +++ /dev/null @@ -1,66 +0,0 @@ -export interface PendingBottle { - id: string; - imageBase64: string; - timestamp: number; -} - -const DB_NAME = 'WhiskyVaultOffline'; -const STORE_NAME = 'pendingCaptures'; -const DB_VERSION = 1; - -export const openDB = (): Promise => { - return new Promise((resolve, reject) => { - const request = indexedDB.open(DB_NAME, DB_VERSION); - - request.onupgradeneeded = (event) => { - const db = (event.target as IDBOpenDBRequest).result; - if (!db.objectStoreNames.contains(STORE_NAME)) { - db.createObjectStore(STORE_NAME, { keyPath: 'id' }); - } - }; - - request.onsuccess = (event) => { - resolve((event.target as IDBOpenDBRequest).result); - }; - - request.onerror = (event) => { - reject((event.target as IDBOpenDBRequest).error); - }; - }); -}; - -export const savePendingBottle = async (bottle: PendingBottle): Promise => { - const db = await openDB(); - return new Promise((resolve, reject) => { - const transaction = db.transaction(STORE_NAME, 'readwrite'); - const store = transaction.objectStore(STORE_NAME); - const request = store.put(bottle); - - request.onsuccess = () => resolve(); - request.onerror = () => reject(request.error); - }); -}; - -export const getAllPendingBottles = async (): Promise => { - const db = await openDB(); - return new Promise((resolve, reject) => { - const transaction = db.transaction(STORE_NAME, 'readonly'); - const store = transaction.objectStore(STORE_NAME); - const request = store.getAll(); - - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error); - }); -}; - -export const deletePendingBottle = async (id: string): Promise => { - const db = await openDB(); - return new Promise((resolve, reject) => { - const transaction = db.transaction(STORE_NAME, 'readwrite'); - const store = transaction.objectStore(STORE_NAME); - const request = store.delete(id); - - request.onsuccess = () => resolve(); - request.onerror = () => reject(request.error); - }); -}; diff --git a/src/services/save-tasting.ts b/src/services/save-tasting.ts index e7fe53d..b18cd64 100644 --- a/src/services/save-tasting.ts +++ b/src/services/save-tasting.ts @@ -15,6 +15,7 @@ export async function saveTasting(data: { is_sample?: boolean; buddy_ids?: string[]; tag_ids?: string[]; + tasted_at?: string; }) { const supabase = createServerActionClient({ cookies }); @@ -41,6 +42,7 @@ export async function saveTasting(data: { palate_notes: data.palate_notes, finish_notes: data.finish_notes, is_sample: data.is_sample || false, + created_at: data.tasted_at || undefined, }) .select() .single();