feat: Add Flight Recorder, Timeline, ABV Curve and Offline Bottle Caching with Draft Notes support

This commit is contained in:
2025-12-19 20:45:20 +01:00
parent 24e243fff8
commit e8c3032954
13 changed files with 864 additions and 337 deletions

View File

@@ -0,0 +1,209 @@
'use client';
import React from 'react';
import Link from 'next/link';
import { ChevronLeft, Calendar, Award, Droplets, MapPin, Tag, ExternalLink, Package, Info, Loader2, WifiOff } from 'lucide-react';
import { getStorageUrl } from '@/lib/supabase';
import TastingNoteForm from '@/components/TastingNoteForm';
import TastingList from '@/components/TastingList';
import DeleteBottleButton from '@/components/DeleteBottleButton';
import EditBottleForm from '@/components/EditBottleForm';
import { useBottleData } from '@/hooks/useBottleData';
import { useI18n } from '@/i18n/I18nContext';
interface BottleDetailsProps {
bottleId: string;
sessionId?: string;
userId?: string;
}
export default function BottleDetails({ bottleId, sessionId, userId }: BottleDetailsProps) {
const { t } = useI18n();
const { bottle, tastings, loading, error, isOffline } = useBottleData(bottleId);
if (loading) {
return (
<div className="min-h-[60vh] flex flex-col items-center justify-center gap-4">
<Loader2 size={48} className="animate-spin text-amber-600" />
<p className="text-zinc-500 font-bold animate-pulse uppercase tracking-widest text-xs">{t('common.loading')}</p>
</div>
);
}
if (!bottle && !loading) {
return (
<div className="min-h-[60vh] flex flex-col items-center justify-center gap-6 p-6 text-center">
<div className="w-20 h-20 bg-zinc-100 dark:bg-zinc-900 rounded-full flex items-center justify-center text-zinc-400">
<WifiOff size={40} />
</div>
<div>
<h2 className="text-xl font-black text-zinc-900 dark:text-white mb-2">Flasche nicht verfügbar</h2>
<p className="text-zinc-500 text-sm max-w-xs mx-auto">
Inhalte konnten nicht geladen werden. Bitte stelle eine Internetverbindung her, um diese Flasche zum ersten Mal zu laden.
</p>
</div>
<Link href="/" className="px-6 py-3 bg-amber-600 text-white rounded-2xl text-sm font-black uppercase tracking-widest shadow-xl shadow-amber-600/20">
Zurück zum Vault
</Link>
</div>
);
}
if (!bottle) return null; // Should not happen due to check above
return (
<div className="max-w-4xl mx-auto space-y-6 md:space-y-12">
{/* Back Button */}
<Link
href={`/${sessionId ? `?session_id=${sessionId}` : ''}`}
className="inline-flex items-center gap-2 text-zinc-500 hover:text-amber-600 transition-colors font-medium mb-4"
>
<ChevronLeft size={20} />
Zurück zur Sammlung
</Link>
{isOffline && (
<div className="bg-amber-600/10 border border-amber-600/20 p-3 rounded-2xl flex items-center gap-3 animate-in fade-in slide-in-from-top-2">
<WifiOff size={16} className="text-amber-600" />
<p className="text-[10px] font-black uppercase tracking-widest text-amber-700">Offline-Modus: Daten aus dem Cache</p>
</div>
)}
{/* Hero Section */}
<section className="grid grid-cols-1 md:grid-cols-2 gap-8 items-start">
<div className="aspect-[4/5] rounded-3xl overflow-hidden shadow-2xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900">
<img
src={getStorageUrl(bottle.image_url)}
alt={bottle.name}
className="w-full h-full object-cover"
/>
</div>
<div className="space-y-6">
<div>
<h1 className="text-2xl md:text-4xl font-black text-zinc-900 dark:text-white tracking-tighter leading-tight">
{bottle.name}
</h1>
<p className="text-sm md:text-xl text-amber-600 font-bold mt-1 uppercase tracking-widest">{bottle.distillery}</p>
{bottle.whiskybase_id && (
<div className="mt-4">
<a
href={`https://www.whiskybase.com/whiskies/whisky/${bottle.whiskybase_id}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 bg-[#db0000] text-white rounded-xl text-sm font-bold shadow-lg shadow-red-600/20 hover:scale-[1.05] transition-transform"
>
<ExternalLink size={16} />
Whiskybase ID: {bottle.whiskybase_id}
</a>
</div>
)}
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<div className="p-4 bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-100 dark:border-zinc-800 shadow-sm flex flex-col justify-between">
<div>
<div className="flex items-center gap-2 text-zinc-400 text-[10px] font-black uppercase mb-1">
<Tag size={12} /> Kategorie
</div>
<div className="font-bold text-sm dark:text-zinc-200">{bottle.category || '-'}</div>
</div>
</div>
<div className="p-4 bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-100 dark:border-zinc-800 shadow-sm">
<div className="flex items-center gap-2 text-zinc-400 text-[10px] font-black uppercase mb-1">
<Droplets size={12} /> Alkoholgehalt
</div>
<div className="font-bold text-sm dark:text-zinc-200">{bottle.abv}% Vol.</div>
</div>
<div className="p-4 bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-100 dark:border-zinc-800 shadow-sm">
<div className="flex items-center gap-2 text-zinc-400 text-[10px] font-black uppercase mb-1">
<Award size={12} /> Alter
</div>
<div className="font-bold text-sm dark:text-zinc-200">{bottle.age ? `${bottle.age} J.` : '-'}</div>
</div>
{bottle.distilled_at && (
<div className="p-4 bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-100 dark:border-zinc-800 shadow-sm">
<div className="flex items-center gap-2 text-zinc-400 text-[10px] font-black uppercase mb-1">
<Calendar size={12} /> Destilliert
</div>
<div className="font-bold text-sm dark:text-zinc-200">{bottle.distilled_at}</div>
</div>
)}
{bottle.bottled_at && (
<div className="p-4 bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-100 dark:border-zinc-800 shadow-sm">
<div className="flex items-center gap-2 text-zinc-400 text-[10px] font-black uppercase mb-1">
<Package size={12} /> Abgefüllt
</div>
<div className="font-bold text-sm dark:text-zinc-200">{bottle.bottled_at}</div>
</div>
)}
{bottle.batch_info && (
<div className="p-4 bg-zinc-50 dark:bg-zinc-800/30 rounded-2xl border border-dashed border-zinc-200 dark:border-zinc-700/50 md:col-span-1">
<div className="flex items-center gap-2 text-zinc-400 text-[10px] font-black uppercase mb-1">
<Info size={12} /> Batch / Code
</div>
<div className="font-mono text-xs dark:text-zinc-300 truncate" title={bottle.batch_info}>{bottle.batch_info}</div>
</div>
)}
<div className="p-4 bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-100 dark:border-zinc-800 shadow-sm">
<div className="flex items-center gap-2 text-zinc-400 text-[10px] font-black uppercase mb-1">
<Calendar size={12} /> Letzter Dram
</div>
<div className="font-bold text-sm dark:text-zinc-200">
{tastings && tastings.length > 0
? new Date(tastings[0].created_at).toLocaleDateString('de-DE')
: 'Noch nie'}
</div>
</div>
</div>
<div className="pt-2 flex flex-wrap gap-4">
{isOffline ? (
<div className="w-full p-4 bg-zinc-100 dark:bg-zinc-800/50 border border-dashed border-zinc-200 dark:border-zinc-700 rounded-2xl flex items-center justify-center gap-2">
<Info size={14} className="text-zinc-400" />
<span className="text-[10px] font-black uppercase text-zinc-400 tracking-widest">Bearbeiten & Löschen nur online möglich</span>
</div>
) : (
<>
<EditBottleForm bottle={bottle as any} />
<DeleteBottleButton bottleId={bottle.id} />
</>
)}
</div>
</div>
</section>
<hr className="border-zinc-200 dark:border-zinc-800" />
{/* Tasting Notes Section */}
<section className="space-y-8">
<div className="flex flex-col md:flex-row justify-between items-start md:items-end gap-4">
<div>
<h2 className="text-3xl font-black text-zinc-900 dark:text-white tracking-tight">Tasting Notes</h2>
<p className="text-zinc-500 mt-1">Hier findest du deine bisherigen Eindrücke.</p>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 md:gap-8 items-start">
{/* Form */}
<div className="lg:col-span-1 border border-zinc-200 dark:border-zinc-800 rounded-3xl p-6 bg-white dark:bg-zinc-900/50 md:sticky md:top-24">
<h3 className="text-lg font-bold mb-6 flex items-center gap-2 text-amber-600">
<Droplets size={20} /> Dram bewerten
</h3>
<TastingNoteForm bottleId={bottle.id} sessionId={sessionId} />
</div>
{/* List */}
<div className="lg:col-span-2">
<TastingList initialTastings={tastings as any || []} currentUserId={userId} />
</div>
</div>
</section>
</div>
);
}

View File

@@ -126,6 +126,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
if (!navigator.onLine) {
console.log('Offline detected. Queuing image...');
await db.pending_scans.add({
temp_id: crypto.randomUUID(),
imageBase64: compressedBase64,
timestamp: Date.now(),
provider: aiProvider,
@@ -167,6 +168,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
if (isNetworkError) {
console.log('Network issue detected during scan. Queuing...');
await db.pending_scans.add({
temp_id: crypto.randomUUID(),
imageBase64: compressedBase64,
timestamp: Date.now(),
provider: aiProvider,

View File

@@ -0,0 +1,133 @@
'use client';
import React from 'react';
import { Activity, AlertCircle, TrendingUp, Zap } from 'lucide-react';
interface ABVTasting {
id: string;
abv: number;
tasted_at: string;
}
interface SessionABVCurveProps {
tastings: ABVTasting[];
}
export default function SessionABVCurve({ tastings }: SessionABVCurveProps) {
if (!tastings || tastings.length < 2) {
return (
<div className="p-6 bg-zinc-50 dark:bg-zinc-900/50 rounded-3xl border border-dashed border-zinc-200 dark:border-zinc-800 text-center">
<Activity size={24} className="mx-auto text-zinc-300 mb-2" />
<p className="text-[10px] text-zinc-500 font-bold uppercase tracking-widest">Kurve wird ab 2 Drams berechnet</p>
</div>
);
}
const sorted = [...tastings].sort((a, b) => new Date(a.tasted_at).getTime() - new Date(b.tasted_at).getTime());
// Normalize data: Y-axis is ABV (say 40-65 range), X-axis is time or just sequence index
const minAbv = Math.min(...sorted.map(t => t.abv));
const maxAbv = Math.max(...sorted.map(t => t.abv));
const range = Math.max(maxAbv - minAbv, 10); // at least 10 point range for scale
// SVG Dimensions
const width = 400;
const height = 150;
const padding = 20;
const getX = (index: number) => padding + (index * (width - 2 * padding) / (sorted.length - 1));
const getY = (abv: number) => {
const normalized = (abv - (minAbv - 2)) / (range + 4);
return height - padding - (normalized * (height - 2 * padding));
};
const points = sorted.map((t, i) => `${getX(i)},${getY(t.abv)}`).join(' ');
// Check for dangerous slope (sudden high ABV jump)
const hasBigJump = sorted.some((t, i) => i > 0 && t.abv - sorted[i - 1].abv > 10);
return (
<div className="bg-zinc-900 rounded-3xl p-5 border border-white/5 shadow-2xl overflow-hidden relative group">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<TrendingUp size={16} className="text-amber-500" />
<div>
<h4 className="text-[10px] font-black text-zinc-500 uppercase tracking-widest leading-none">ABV Kurve (Session)</h4>
<p className="text-[8px] text-zinc-600 font-bold uppercase tracking-tighter">Alcohol By Volume Progression</p>
</div>
</div>
{hasBigJump && (
<div className="flex items-center gap-1.5 px-2 py-1 bg-red-500/10 border border-red-500/20 rounded-lg animate-pulse">
<AlertCircle size={10} className="text-red-500" />
<span className="text-[8px] font-black text-red-500 uppercase tracking-tighter">Zick-Zack Gefahr</span>
</div>
)}
</div>
<div className="relative h-[150px] w-full">
{/* Grid Lines */}
<div className="absolute inset-0 flex flex-col justify-between opacity-10 pointer-events-none">
{[1, 2, 3, 4].map(i => <div key={i} className="border-t border-white" />)}
</div>
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-full drop-shadow-[0_0_15px_rgba(217,119,6,0.2)]">
{/* Gradient under line */}
<defs>
<linearGradient id="curveGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#d97706" stopOpacity="0.4" />
<stop offset="100%" stopColor="#d97706" stopOpacity="0" />
</linearGradient>
</defs>
<path
d={`M ${getX(0)} ${height} L ${points} L ${getX(sorted.length - 1)} ${height} Z`}
fill="url(#curveGradient)"
/>
<polyline
points={points}
fill="none"
stroke="#d97706"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
className="transition-all duration-700 ease-out"
/>
{sorted.map((t, i) => (
<g key={t.id} className="group/dot">
<circle
cx={getX(i)}
cy={getY(t.abv)}
r="4"
fill="#d97706"
className="transition-all hover:r-6 cursor-help"
/>
<text
x={getX(i)}
y={getY(t.abv) - 10}
textAnchor="middle"
className="text-[8px] fill-zinc-400 font-black opacity-0 group-hover/dot:opacity-100 transition-opacity"
>
{t.abv}%
</text>
</g>
))}
</svg>
</div>
<div className="mt-4 grid grid-cols-2 gap-3 border-t border-white/5 pt-4">
<div className="flex flex-col">
<span className="text-[8px] font-black text-zinc-500 uppercase tracking-widest">Ø Alkohol</span>
<span className="text-sm font-black text-white">{(sorted.reduce((acc, t) => acc + t.abv, 0) / sorted.length).toFixed(1)}%</span>
</div>
<div className="flex flex-col items-end">
<span className="text-[8px] font-black text-zinc-500 uppercase tracking-widest">Status</span>
<span className={`text-[10px] font-black uppercase tracking-widest ${hasBigJump ? 'text-red-500' : 'text-green-500'}`}>
{hasBigJump ? 'Instabil' : 'Optimal'}
</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,118 @@
'use client';
import React from 'react';
import { CheckCircle2, AlertTriangle, Clock, Droplets, Info } from 'lucide-react';
import Link from 'next/link';
interface TimelineTasting {
id: string;
bottle_id: string;
bottle_name: string;
tasted_at: string;
rating: number;
tags: string[];
category?: string;
}
interface SessionTimelineProps {
tastings: TimelineTasting[];
sessionStart?: string;
}
// Keywords that indicate a "Peat Bomb"
const SMOKY_KEYWORDS = ['rauch', 'torf', 'smoke', 'peat', 'islay', 'ash', 'lagerfeuer', 'campfire', 'asphalte'];
export default function SessionTimeline({ tastings, sessionStart }: SessionTimelineProps) {
if (!tastings || tastings.length === 0) {
return (
<div className="p-8 text-center bg-zinc-50 dark:bg-zinc-900/50 rounded-3xl border border-dashed border-zinc-200 dark:border-zinc-800">
<Clock size={32} className="mx-auto text-zinc-300 mb-3" />
<p className="text-zinc-500 text-sm font-medium italic">Noch keine Dram-Historie vorhanden.</p>
</div>
);
}
// Sort by tasted_at
const sortedTastings = [...tastings].sort((a, b) =>
new Date(a.tasted_at).getTime() - new Date(b.tasted_at).getTime()
);
const firstTastingTime = sessionStart ? new Date(sessionStart).getTime() : new Date(sortedTastings[0].tasted_at).getTime();
const checkIsSmoky = (tasting: TimelineTasting) => {
const textToSearch = (tasting.tags.join(' ') + ' ' + (tasting.category || '')).toLowerCase();
return SMOKY_KEYWORDS.some(keyword => textToSearch.includes(keyword));
};
return (
<div className="relative pl-8 space-y-8 before:absolute before:inset-0 before:left-[11px] before:w-[2px] before:bg-zinc-200 dark:before:bg-zinc-800 before:h-full">
{sortedTastings.map((tasting, index) => {
const currentTime = new Date(tasting.tasted_at).getTime();
const diffMinutes = Math.round((currentTime - firstTastingTime) / (1000 * 60));
const isSmoky = checkIsSmoky(tasting);
// Palette warning logic: if this dram is peaty, warn about the NEXT one (metaphorically)
// Or if the PREVIOUS was peaty, show a warning on this one.
const wasPreviousSmoky = index > 0 && checkIsSmoky(sortedTastings[index - 1]);
const timeSinceLastDram = index > 0
? Math.round((currentTime - new Date(sortedTastings[index - 1].tasted_at).getTime()) / (1000 * 60))
: 0;
return (
<div key={tasting.id} className="relative group">
{/* Dot */}
<div className={`absolute -left-[30px] w-6 h-6 rounded-full border-4 border-white dark:border-zinc-900 shadow-sm z-10 flex items-center justify-center ${isSmoky ? 'bg-amber-600' : 'bg-zinc-400'}`}>
{isSmoky && <Droplets size={10} className="text-white fill-white" />}
</div>
{/* Relative Time */}
<div className="absolute -left-16 -top-1 w-12 text-right">
<span className="text-[10px] font-black text-zinc-400 dark:text-zinc-600 block leading-none">
{index === 0 ? 'START' : `+${diffMinutes}'`}
</span>
</div>
<div className="bg-white dark:bg-zinc-900 p-4 rounded-2xl border border-zinc-200 dark:border-zinc-800 shadow-sm hover:shadow-md transition-shadow group-hover:border-amber-500/30">
<div className="flex justify-between items-start gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-[10px] font-black text-amber-600 uppercase tracking-widest">Dram #{index + 1}</span>
{isSmoky && (
<span className="bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 text-[8px] font-black px-1.5 py-0.5 rounded-md uppercase tracking-tighter">Peat Bomb</span>
)}
</div>
<Link
href={`/bottles/${tasting.bottle_id}`}
className="text-sm font-bold text-zinc-800 dark:text-zinc-100 hover:text-amber-600 truncate block"
>
{tasting.bottle_name}
</Link>
<div className="mt-2 flex flex-wrap gap-1">
{tasting.tags.slice(0, 3).map(tag => (
<span key={tag} className="text-[9px] text-zinc-500 dark:text-zinc-500 bg-zinc-100 dark:bg-zinc-800/50 px-2 py-0.5 rounded-full">
{tag}
</span>
))}
</div>
</div>
<div className="shrink-0 flex flex-col items-end">
<div className="text-lg font-black text-zinc-900 dark:text-white">{tasting.rating}</div>
<div className="text-[9px] font-bold text-zinc-400 uppercase tracking-tighter">Punkte</div>
</div>
</div>
{wasPreviousSmoky && timeSinceLastDram < 20 && (
<div className="mt-4 p-2 bg-amber-50 dark:bg-amber-900/10 border border-amber-200/50 dark:border-amber-900/30 rounded-xl flex items-center gap-2 animate-in slide-in-from-top-1">
<AlertTriangle size={12} className="text-amber-600 shrink-0" />
<p className="text-[9px] text-amber-800 dark:text-amber-400 font-bold leading-tight">
Achtung: Gaumen war noch torf-belegt (nur {timeSinceLastDram}m Abstand).
</p>
</div>
)}
</div>
</div>
);
})}
</div>
);
}

View File

@@ -9,6 +9,7 @@ import { useSession } from '@/context/SessionContext';
import TagSelector from './TagSelector';
import { useLiveQuery } from 'dexie-react-hooks';
import { db } from '@/lib/db';
import { AlertTriangle } from 'lucide-react';
interface Buddy {
id: string;
@@ -16,11 +17,13 @@ interface Buddy {
}
interface TastingNoteFormProps {
bottleId: string;
bottleId?: string; // Real ID
pendingBottleId?: string; // Temp ID for queued scans
sessionId?: string;
onSuccess?: () => void;
}
export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteFormProps) {
export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId, onSuccess }: TastingNoteFormProps) {
const { t } = useI18n();
const supabase = createClient();
const [rating, setRating] = useState(85);
@@ -38,11 +41,15 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
const [suggestedTags, setSuggestedTags] = useState<string[]>([]);
const [suggestedCustomTags, setSuggestedCustomTags] = useState<string[]>([]);
const { activeSession } = useSession();
const [lastDramInSession, setLastDramInSession] = useState<{ name: string; isSmoky: boolean; timestamp: number } | null>(null);
const [showPaletteWarning, setShowPaletteWarning] = useState(false);
const effectiveSessionId = sessionId || activeSession?.id;
useEffect(() => {
const fetchData = async () => {
if (!bottleId) return;
// Fetch Bottle Suggestions
const { data: bottleData } = await supabase
.from('bottles')
@@ -57,7 +64,7 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
setSuggestedCustomTags(bottleData.suggested_custom_tags);
}
// If Session ID, fetch session participants and pre-select them
// If Session ID, fetch session participants and pre-select them, and fetch last dram
if (effectiveSessionId) {
const { data: participants } = await supabase
.from('session_participants')
@@ -67,13 +74,59 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
if (participants) {
setSelectedBuddyIds(participants.map(p => p.buddy_id));
}
// Fetch last dram for Palette Checker (Only online)
if (navigator.onLine) {
const { data: lastTastings } = await supabase
.from('tastings')
.select(`
id,
tasted_at,
bottles(name, category),
tasting_tags(tags(name))
`)
.eq('session_id', effectiveSessionId)
.order('tasted_at', { ascending: false })
.limit(1);
if (lastTastings && lastTastings.length > 0) {
const last = lastTastings[0];
const tags = (last as any).tasting_tags?.map((t: any) => t.tags.name) || [];
const category = (last as any).bottles?.category || '';
const text = (tags.join(' ') + ' ' + category).toLowerCase();
const smokyKeywords = ['rauch', 'torf', 'smoke', 'peat', 'islay', 'ash', 'lagerfeuer'];
const isSmoky = smokyKeywords.some(kw => text.includes(kw));
setLastDramInSession({
name: (last as any).bottles?.name || 'Unbekannt',
isSmoky,
timestamp: new Date(last.tasted_at).getTime()
});
}
}
} else {
setSelectedBuddyIds([]);
setLastDramInSession(null);
}
};
fetchData();
}, [supabase, effectiveSessionId, bottleId]);
// Live Palette Checker Logic
useEffect(() => {
if (lastDramInSession?.isSmoky) {
const now = Date.now();
const diffMin = (now - lastDramInSession.timestamp) / (1000 * 60);
if (diffMin < 20) {
setShowPaletteWarning(true);
} else {
setShowPaletteWarning(false);
}
} else {
setShowPaletteWarning(false);
}
}, [lastDramInSession]);
const toggleBuddy = (id: string) => {
setSelectedBuddyIds(prev =>
prev.includes(id) ? prev.filter(bid => bid !== id) : [...prev, id]
@@ -109,7 +162,7 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
setError(null);
const data = {
bottle_id: bottleId,
bottle_id: bottleId as string,
session_id: effectiveSessionId,
rating,
nose_notes: nose,
@@ -121,15 +174,17 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
};
try {
if (!navigator.onLine) {
if (!navigator.onLine || pendingBottleId) {
// Save to Offline DB
await db.pending_tastings.add({
bottle_id: bottleId,
pending_bottle_id: pendingBottleId,
data,
tasted_at: new Date().toISOString()
});
clearForm();
setLoading(false);
onSuccess?.();
return;
}
@@ -137,6 +192,7 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
if (result.success) {
clearForm();
onSuccess?.();
} else {
setError(result.error || t('common.error'));
}
@@ -161,6 +217,28 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
</div>
)}
{showPaletteWarning && (
<div className="p-4 bg-amber-500/10 border border-amber-500/20 rounded-2xl flex items-start gap-3 animate-in fade-in slide-in-from-top-2">
<AlertTriangle size={20} className="text-amber-500 shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="text-[10px] font-black uppercase tracking-wider text-amber-600">Palette-Checker Warnung</p>
<p className="text-xs font-bold text-amber-900 dark:text-amber-200">
Dein letzter Dram war "{lastDramInSession?.name}".
</p>
<p className="text-[10px] text-amber-800/80 dark:text-amber-400/80 leading-relaxed font-medium">
Da er sehr torfig war und erst vor Kurzem verkostet wurde, könnten deine Geschmacksnerven noch beeinträchtigt sein. Trink am besten etwas Wasser!
</p>
<button
type="button"
onClick={() => setShowPaletteWarning(false)}
className="text-[9px] font-black uppercase text-amber-600 underline"
>
Ignorieren
</button>
</div>
</div>
)}
<div className="space-y-4">
<div className="flex items-center justify-between">
<label className="text-[11px] font-black text-zinc-400 uppercase tracking-widest flex items-center gap-2">

View File

@@ -7,7 +7,8 @@ import { analyzeBottle } from '@/services/analyze-bottle';
import { saveBottle } from '@/services/save-bottle';
import { saveTasting } from '@/services/save-tasting';
import { createClient } from '@/lib/supabase/client';
import { RefreshCw, CheckCircle2, AlertCircle, Loader2, Info } from 'lucide-react';
import { RefreshCw, CheckCircle2, AlertCircle, Loader2, Info, Send } from 'lucide-react';
import TastingNoteForm from './TastingNoteForm';
export default function UploadQueue() {
const supabase = createClient();
@@ -15,6 +16,7 @@ export default function UploadQueue() {
const [currentProgress, setCurrentProgress] = useState<{ id: string, status: string } | null>(null);
const [isCollapsed, setIsCollapsed] = useState(false);
const [completedItems, setCompletedItems] = useState<{ id: string; name: string; bottleId?: string; type: 'scan' | 'tasting' }[]>([]);
const [activeNoteScanId, setActiveNoteScanId] = useState<string | null>(null);
const pendingScans = useLiveQuery(() => db.pending_scans.toArray(), [], [] as PendingScan[]);
const pendingTastings = useLiveQuery(() => db.pending_tastings.toArray(), [], [] as PendingTasting[]);
@@ -45,10 +47,27 @@ export default function UploadQueue() {
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: save.data.id,
bottleId: newBottleId,
type: 'scan'
}]);
await db.pending_scans.delete(item.id!);
@@ -66,13 +85,19 @@ export default function UploadQueue() {
// 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 itemId = `tasting-${item.id}`;
setCurrentProgress({ id: itemId, status: 'Synchronisiere Tasting...' });
try {
const result = await saveTasting({
...item.data,
is_sample: item.data.is_sample ?? false,
bottle_id: item.bottle_id,
bottle_id: item.bottle_id as string,
tasted_at: item.tasted_at
});
if (result.success) {
@@ -184,26 +209,46 @@ export default function UploadQueue() {
{/* Scans */}
{pendingScans.map((item) => (
<div key={`scan-${item.id}`} className="group flex items-center justify-between p-2 rounded-xl bg-white/5 border border-white/5 hover:bg-white/10 transition-colors">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-zinc-800 overflow-hidden ring-1 ring-white/10 shrink-0">
<img src={item.imageBase64} className="w-full h-full object-cover opacity-60 group-hover:opacity-100 transition-opacity" />
<div key={`scan-${item.id}`} className="flex flex-col gap-2 p-2 rounded-xl bg-white/5 border border-white/5 transition-colors">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-zinc-800 overflow-hidden ring-1 ring-white/10 shrink-0">
<img src={item.imageBase64} className="w-full h-full object-cover opacity-60" />
</div>
<div className="flex flex-col gap-0.5">
<span className="text-[10px] font-black text-amber-500/80 uppercase tracking-widest">Magic Shot</span>
<span className="text-[11px] font-medium text-zinc-300">
{currentProgress?.id === `scan-${item.id}` ? (
<span className="flex items-center gap-1.5">
<Loader2 size={10} className="animate-spin" />
{currentProgress.status}
</span>
) : 'Wartet auf Verbindung...'}
</span>
</div>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-[10px] font-black text-amber-500/80 uppercase tracking-widest">Magic Shot</span>
<span className="text-[11px] font-medium text-zinc-300">
{currentProgress?.id === `scan-${item.id}` ? (
<span className="flex items-center gap-1.5">
<Loader2 size={10} className="animate-spin" />
{currentProgress.status}
</span>
) : 'Wartet auf Verbindung...'}
</span>
<div className="text-[9px] text-zinc-500 font-bold whitespace-nowrap ml-4">
{new Date(item.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</div>
</div>
<div className="text-[9px] text-zinc-500 font-bold whitespace-nowrap ml-4">
{new Date(item.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</div>
{/* Link for adding tasting notes to pending scan */}
<button
onClick={() => setActiveNoteScanId(activeNoteScanId === item.temp_id ? null : item.temp_id)}
className="w-full py-2 bg-amber-600/10 hover:bg-amber-600/20 text-amber-500 text-[10px] font-black uppercase rounded-lg transition-all flex items-center justify-center gap-2 border border-amber-500/10"
>
<Send size={10} />
{activeNoteScanId === item.temp_id ? 'Abbrechen' : 'Notiz hinzufügen'}
</button>
{activeNoteScanId === item.temp_id && (
<div className="mt-2 p-4 bg-zinc-800 rounded-2xl border border-white/5 animate-in slide-in-from-top-2">
<TastingNoteForm
pendingBottleId={item.temp_id}
onSuccess={() => setActiveNoteScanId(null)}
/>
</div>
)}
</div>
))}
@@ -215,9 +260,13 @@ export default function UploadQueue() {
<div className="text-sm font-black text-amber-500">{item.data.rating}</div>
</div>
<div className="flex flex-col gap-0.5">
<span className="text-[10px] font-black text-amber-500/80 uppercase tracking-widest">Tasting Node</span>
<span className="text-[10px] font-black text-amber-500/80 uppercase tracking-widest">
{item.pending_bottle_id ? 'Draft Notiz' : 'Tasting Note'}
</span>
<span className="text-[11px] font-medium text-zinc-300">
{currentProgress?.id === `tasting-${item.id}` ? (
{item.pending_bottle_id ? (
<span className="text-amber-500/60 italic">Wartet auf Scan...</span>
) : currentProgress?.id === `tasting-${item.id}` ? (
<span className="flex items-center gap-1.5">
<Loader2 size={10} className="animate-spin" />
{currentProgress.status}
@@ -255,3 +304,4 @@ export default function UploadQueue() {
</div>
);
}