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

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