feat: implement robust offline-first sync with Dexie.js

- Migrated to Dexie.js for IndexedDB management
- Added optimistic UI for tasting notes with 'Wartet auf Sync' badge
- Implemented background caching for tags and buddies
- Unified scanning and tasting sync in a global UploadQueue
This commit is contained in:
2025-12-19 13:40:56 +01:00
parent e08a18b2d5
commit 60ca3a6190
12 changed files with 417 additions and 181 deletions

104
.offline Normal file
View File

@@ -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

View File

@@ -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({
<AuthListener />
<ActiveSessionBanner />
<MainContentWrapper>
<SyncHandler />
<PWARegistration />
<OfflineIndicator />
<UploadQueue />

View File

@@ -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;

View File

@@ -0,0 +1,8 @@
'use client';
import { useCacheSync } from '@/hooks/useCacheSync';
export default function SyncHandler() {
useCacheSync();
return null;
}

View File

@@ -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<Tag[]>([]);
const [search, setSearch] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [isCreating, setIsCreating] = useState(false);
const [creatingSuggestion, setCreatingSuggestion] = useState<string | null>(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;

View File

@@ -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<string | null>(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'}
</span>
{(note as any).isPending && (
<div className="bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400 px-2 py-0.5 rounded-lg text-[10px] font-black uppercase tracking-tighter flex items-center gap-1.5 animate-pulse">
<Clock size={10} />
Wartet auf Sync...
</div>
)}
<div className="text-[10px] text-zinc-500 font-bold bg-zinc-100 dark:bg-zinc-800 px-2 py-1 rounded-lg flex items-center gap-1">
<Clock size={10} />
{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
<Calendar size={12} />
{new Date(note.created_at).toLocaleDateString('de-DE')}
</div>
{(!currentUserId || note.user_id === currentUserId) && (
{(!currentUserId || note.user_id === currentUserId) && !(note as any).isPending && (
<button
onClick={() => handleDelete(note.id, note.bottle_id)}
disabled={!!isDeleting}

View File

@@ -7,6 +7,8 @@ import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
import { useI18n } from '@/i18n/I18nContext';
import { useSession } from '@/context/SessionContext';
import TagSelector from './TagSelector';
import { useLiveQuery } from 'dexie-react-hooks';
import { db } from '@/lib/db';
interface Buddy {
id: string;
@@ -28,7 +30,7 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
const [isSample, setIsSample] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [buddies, setBuddies] = useState<Buddy[]>([]);
const buddies = useLiveQuery(() => db.cache_buddies.toArray(), [], [] as Buddy[]);
const [selectedBuddyIds, setSelectedBuddyIds] = useState<string[]>([]);
const [noseTagIds, setNoseTagIds] = useState<string[]>([]);
const [palateTagIds, setPalateTagIds] = useState<string[]>([]);
@@ -41,10 +43,6 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
useEffect(() => {
const fetchData = async () => {
// Fetch All Buddies
const { data: buddiesData } = await supabase.from('buddies').select('id, name').order('name');
setBuddies(buddiesData || []);
// Fetch Bottle Suggestions
const { data: bottleData } = await supabase
.from('bottles')
@@ -74,7 +72,7 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
}
};
fetchData();
}, [effectiveSessionId, bottleId]);
}, [supabase, effectiveSessionId, bottleId]);
const toggleBuddy = (id: string) => {
setSelectedBuddyIds(prev =>
@@ -94,33 +92,51 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
setFinishTagIds(prev => prev.includes(id) ? prev.filter(tid => tid !== id) : [...prev, id]);
};
const clearForm = () => {
setNose('');
setPalate('');
setFinish('');
setSelectedBuddyIds([]);
setNoseTagIds([]);
setPalateTagIds([]);
setFinishTagIds([]);
setError(null);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
const data = {
bottle_id: bottleId,
session_id: effectiveSessionId,
rating,
nose_notes: nose,
palate_notes: palate,
finish_notes: finish,
is_sample: isSample,
buddy_ids: selectedBuddyIds,
tag_ids: [...noseTagIds, ...palateTagIds, ...finishTagIds],
};
try {
const result = await saveTasting({
bottle_id: bottleId,
session_id: effectiveSessionId,
rating,
nose_notes: nose,
palate_notes: palate,
finish_notes: finish,
is_sample: isSample,
buddy_ids: selectedBuddyIds,
tag_ids: [...noseTagIds, ...palateTagIds, ...finishTagIds],
});
if (!navigator.onLine) {
// Save to Offline DB
await db.pending_tastings.add({
bottle_id: bottleId,
data,
tasted_at: new Date().toISOString()
});
clearForm();
setLoading(false);
return;
}
const result = await saveTasting(data);
if (result.success) {
setNose('');
setPalate('');
setFinish('');
setSelectedBuddyIds([]);
setNoseTagIds([]);
setPalateTagIds([]);
setFinishTagIds([]);
// We don't need to manually refresh because of revalidatePath in the server action
clearForm();
} else {
setError(result.error || t('common.error'));
}

View File

@@ -1,25 +1,26 @@
'use client';
import React, { useEffect, useState, useCallback } from 'react';
import { getAllPendingBottles, deletePendingBottle, PendingBottle } from '@/lib/offline-db';
import { useLiveQuery } from 'dexie-react-hooks';
import { db, PendingScan, PendingTasting } from '@/lib/db';
import { analyzeBottle } from '@/services/analyze-bottle';
import { saveBottle } from '@/services/save-bottle';
import { saveTasting } from '@/services/save-tasting';
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
import { RefreshCw, CheckCircle2, AlertCircle, Loader2 } from 'lucide-react';
import { RefreshCw, CheckCircle2, AlertCircle, Loader2, Info } from 'lucide-react';
export default function UploadQueue() {
const supabase = createClientComponentClient();
const [queue, setQueue] = useState<PendingBottle[]>([]);
const [isSyncing, setIsSyncing] = useState(false);
const [currentProgress, setCurrentProgress] = useState<{ id: string, status: string } | null>(null);
const loadQueue = useCallback(async () => {
const pending = await getAllPendingBottles();
setQueue(pending);
}, []);
const pendingScans = useLiveQuery(() => db.pending_scans.toArray(), [], [] as PendingScan[]);
const pendingTastings = useLiveQuery(() => db.pending_tastings.toArray(), [], [] as PendingTasting[]);
const totalInQueue = pendingScans.length + pendingTastings.length;
const syncQueue = useCallback(async () => {
if (isSyncing || !navigator.onLine || queue.length === 0) return;
if (isSyncing || !navigator.onLine || totalInQueue === 0) return;
setIsSyncing(true);
const { data: { user } } = await supabase.auth.getUser();
@@ -30,93 +31,132 @@ export default function UploadQueue() {
return;
}
for (const item of queue) {
setCurrentProgress({ id: item.id, status: 'Analysiere...' });
try {
// 1. Analyze
const analysis = await analyzeBottle(item.imageBase64);
if (analysis.success && analysis.data) {
setCurrentProgress({ id: item.id, status: 'Speichere...' });
// 2. Save
const save = await saveBottle(analysis.data, item.imageBase64, user.id);
if (save.success) {
await deletePendingBottle(item.id);
try {
// 1. Sync Scans (Magic Shots)
for (const item of pendingScans) {
const itemId = `scan-${item.id}`;
setCurrentProgress({ id: itemId, status: 'Analysiere Scan...' });
try {
const analysis = await analyzeBottle(item.imageBase64);
if (analysis.success && analysis.data) {
setCurrentProgress({ id: itemId, status: 'Speichere Flasche...' });
const save = await saveBottle(analysis.data, item.imageBase64, user.id);
if (save.success) {
await db.pending_scans.delete(item.id!);
}
} else {
throw new Error(analysis.error || 'Analyse fehlgeschlagen');
}
} catch (err) {
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));
}
} catch (err) {
console.error('Sync failed for item', item.id, err);
}
}
setIsSyncing(false);
setCurrentProgress(null);
loadQueue();
}, [isSyncing, queue, supabase, loadQueue]);
// 2. Sync Tastings
for (const item of pendingTastings) {
const itemId = `tasting-${item.id}`;
setCurrentProgress({ id: itemId, status: 'Synchronisiere Tasting...' });
try {
const result = await saveTasting({
...item.data,
tasted_at: item.tasted_at
});
if (result.success) {
await db.pending_tastings.delete(item.id!);
} else {
throw new Error(result.error);
}
} catch (err) {
console.error('Tasting sync failed:', err);
setCurrentProgress({ id: itemId, status: 'Fehler bei Tasting' });
await new Promise(r => setTimeout(r, 2000));
}
}
} catch (err) {
console.error('Global Sync Error:', err);
} finally {
setIsSyncing(false);
setCurrentProgress(null);
}
}, [isSyncing, pendingScans, pendingTastings, totalInQueue, supabase]);
useEffect(() => {
loadQueue();
// Listen for storage changes (e.g. from CameraCapture)
const interval = setInterval(loadQueue, 5000);
const handleOnline = () => {
console.log('Back online! Triggering sync...');
console.log('Online! Triggering background sync...');
syncQueue();
};
window.addEventListener('online', handleOnline);
return () => {
clearInterval(interval);
window.removeEventListener('online', handleOnline);
};
}, [loadQueue, syncQueue]);
if (queue.length === 0) return null;
// Initial check if we are online and have items
if (navigator.onLine && totalInQueue > 0 && !isSyncing) {
syncQueue();
}
return () => window.removeEventListener('online', handleOnline);
}, [totalInQueue, syncQueue, isSyncing]);
if (totalInQueue === 0) return null;
return (
<div className="fixed bottom-6 right-6 z-50 animate-in slide-in-from-right-10">
<div className="bg-zinc-900 text-white p-4 rounded-2xl shadow-2xl border border-white/10 flex flex-col gap-3 min-w-[280px]">
<div className="bg-zinc-900 text-white p-4 rounded-2xl shadow-2xl border border-white/10 flex flex-col gap-3 min-w-[300px]">
<div className="flex items-center justify-between border-b border-white/10 pb-2">
<div className="flex items-center gap-2">
<RefreshCw size={16} className={isSyncing ? 'animate-spin text-amber-500' : 'text-zinc-400'} />
<span className="text-xs font-black uppercase tracking-widest">Upload Queue</span>
<span className="text-xs font-black uppercase tracking-widest">Global Sync Queue</span>
</div>
<span className="bg-amber-600 text-[10px] font-black px-1.5 py-0.5 rounded-md">
{queue.length}
{totalInQueue} Items
</span>
</div>
<div className="space-y-2">
{queue.slice(0, 3).map((item) => (
<div key={item.id} className="flex items-center justify-between text-[11px] font-medium text-zinc-400">
{/* Scans */}
{pendingScans.map((item) => (
<div key={`scan-${item.id}`} className="flex items-center justify-between text-[11px] font-medium text-zinc-400">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded bg-zinc-800 overflow-hidden">
<div className="w-6 h-6 rounded bg-zinc-800 overflow-hidden ring-1 ring-white/10">
<img src={item.imageBase64} className="w-full h-full object-cover opacity-50" />
</div>
<span className="truncate max-w-[120px]">
{currentProgress?.id === item.id ? currentProgress.status : 'Wartet auf Netz...'}
<span className="truncate max-w-[150px]">
{currentProgress?.id === `scan-${item.id}` ? currentProgress.status : 'Scan wartet...'}
</span>
</div>
{currentProgress?.id === item.id ? (
{currentProgress?.id === `scan-${item.id}` ? (
<Loader2 size={12} className="animate-spin text-amber-500" />
) : (
<AlertCircle size={12} className="text-zinc-600" />
)}
) : <Info size={12} className="text-zinc-600" />}
</div>
))}
{queue.length > 3 && (
<div className="text-[10px] text-zinc-500 text-center font-bold italic pt-1">
+ {queue.length - 3} weitere Flaschen
{/* Tastings */}
{pendingTastings.map((item) => (
<div key={`tasting-${item.id}`} className="flex items-center justify-between text-[11px] font-medium text-zinc-400">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-full bg-amber-900/40 flex items-center justify-center text-[8px] font-bold text-amber-500 ring-1 ring-amber-500/20">
{item.data.rating}
</div>
<span className="truncate max-w-[150px]">
{currentProgress?.id === `tasting-${item.id}` ? currentProgress.status : 'Tasting wartet...'}
</span>
</div>
{currentProgress?.id === `tasting-${item.id}` ? (
<Loader2 size={12} className="animate-spin text-amber-500" />
) : <Info size={12} className="text-zinc-600" />}
</div>
)}
))}
</div>
{navigator.onLine && !isSyncing && (
<button
onClick={syncQueue}
className="w-full py-2 bg-amber-600 hover:bg-amber-500 text-[10px] font-black uppercase rounded-lg transition-colors cursor-pointer"
onClick={() => syncQueue()}
className="w-full py-2.5 bg-zinc-800 hover:bg-zinc-700 text-amber-500 text-[10px] font-black uppercase rounded-xl transition-all border border-white/5 active:scale-95 flex items-center justify-center gap-2 mt-1"
>
Jetzt Synchronisieren
<RefreshCw size={12} />
Sync Erzwingen
</button>
)}
</div>

44
src/hooks/useCacheSync.ts Normal file
View File

@@ -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]);
}

57
src/lib/db.ts Normal file
View File

@@ -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<PendingScan>;
pending_tastings!: Table<PendingTasting>;
cache_tags!: Table<CachedTag>;
cache_buddies!: Table<CachedBuddy>;
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();

View File

@@ -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<IDBDatabase> => {
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<void> => {
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<PendingBottle[]> => {
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<void> => {
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);
});
};

View File

@@ -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();