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

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>