feat: Add Flight Recorder, Timeline, ABV Curve and Offline Bottle Caching with Draft Notes support
This commit is contained in:
77
.aiideas
77
.aiideas
@@ -1,69 +1,46 @@
|
|||||||
1. Feature: Tasting Notes Auto-Fill (Die "Tag-Matching" Strategie)
|
3. Timeline & "Flight Recorder" (Reihenfolge-Logik)
|
||||||
|
|
||||||
Du hast völlig recht: Wenn Gemini einfach wild Text generiert (z.B. "Grüner Granny Smith Apfel"), und deine Datenbank nur "Apfel" kennt, hast du Chaos.
|
Ziel: Die Geschichte des Abends rekonstruieren. Analyse des Konsumverhaltens.
|
||||||
|
Feature: Die Timeline-Ansicht
|
||||||
|
|
||||||
Die Lösung: "Constrained Generation" (Gezwungene Auswahl)
|
Statt einer einfachen Liste, eine vertikale Zeitstrahl-Ansicht.
|
||||||
|
|
||||||
Du fütterst Gemini nicht nur mit dem Bild, sondern auch mit deiner Master-Liste an Tags im Prompt.
|
14:00 Uhr: Start der Session "Whisky Herbst".
|
||||||
Der Workflow:
|
|
||||||
|
|
||||||
Input: Bild vom Label + Deine Liste der System Tags (als JSON-String).
|
14:15 Uhr: Glenfiddich 12 (Mild, Start).
|
||||||
|
|
||||||
|
15:30 Uhr: Laphroaig Cask Strength (Der Gaumen-Killer).
|
||||||
|
|
||||||
|
16:00 Uhr: Auchentoshan (Schmeckt nach nichts mehr, weil Laphroaig davor war).
|
||||||
Frontend:
|
|
||||||
|
|
||||||
Die App empfängt die IDs.
|
Analyse & Warnungen (Smart Features):
|
||||||
|
|
||||||
In der UI werden diese Tags aktiviert/vorausgewählt angezeigt (z.B. farbig hinterlegt).
|
Der "Palette-Checker":
|
||||||
|
|
||||||
Der User sieht: "Vorschlag: Rauch, Vanille".
|
Wenn der User einen extrem rauchigen Whisky (80ppm) loggt und 10 Minuten später einen milden Lowlander eintragen will.
|
||||||
|
|
||||||
Wichtig: Der User kann sie abwählen (wenn er es nicht schmeckt) oder andere aus der Liste hinzufügen.
|
Warnung (lustig): "Achtung! Du hast gerade eine Torfbombe getrunken. Warte lieber noch 10 Min oder trink Wasser, sonst schmeckst du den nächsten nicht!"
|
||||||
|
|
||||||
Das ist der "Sweet Spot". Wir kombinieren die harte Fakten-Extraktion (Metadata) mit der "halluzinierten" aber kontrollierten Sensorik (Tags).
|
ABV-Kurve:
|
||||||
|
|
||||||
Hier ist dein "Master Prompt", der beides erledigt.
|
Ein Liniendiagramm am Ende der Session: Wie hat sich der Alkoholgehalt entwickelt?
|
||||||
Das Konzept der "Constrained Generation"
|
|
||||||
|
|
||||||
Wichtig: Damit Gemini nicht irgendwelche Wörter erfindet, müssen wir ihm deine Tag-Liste im Prompt mitgeben. Ich habe im Prompt einen Platzhalter {AVAILABLE_TAGS_JSON} eingefügt. Diesen musst du in deinem Code (Next.js API Route oder Edge Function) mit deiner echten Tag-Liste ersetzen, bevor du den String an Gemini schickst.
|
Ideal: Langsamer Anstieg.
|
||||||
Der Prompt (Copy & Paste)
|
|
||||||
|
|
||||||
You are a master sommelier and strict database clerk.
|
Gefährlich: Zick-Zack.
|
||||||
Your task is to analyze the whisky bottle image provided.
|
|
||||||
|
|
||||||
PART 1: METADATA EXTRACTION
|
Time-Stamping:
|
||||||
Extract precise metadata from the visible label text.
|
|
||||||
- If the image is NOT a whisky bottle or if you are very unsure, set "is_whisky" to false and provide a low "confidence" score.
|
|
||||||
- If a value is not visible, use null.
|
|
||||||
- Infer the 'Category' (e.g., Islay Single Malt, Bourbon, Rye) based on the Distillery if possible.
|
|
||||||
- Search specifically for a "Whiskybase ID" or "WB ID" on the label (often handwritten or small print).
|
|
||||||
- Search for "Bottle Codes" (Laser codes often on the glass).
|
|
||||||
|
|
||||||
PART 2: SENSORY ANALYSIS (AUTO-FILL)
|
Nutze nicht nur created_at (Upload Zeit), sondern speichere explizit tasted_at.
|
||||||
Based on the identified bottle (using your internal knowledge about this specific release/distillery), select the most appropriate flavor tags.
|
|
||||||
CONSTRAINT: You must ONLY select tags from the following provided list. Do NOT invent new tags.
|
|
||||||
If you recognize the whisky, try to select 3-6 tags that best describe its character.
|
|
||||||
|
|
||||||
AVAILABLE TAGS LIST:
|
Warum? Wenn du 3 Stunden offline warst und dann online gehst, haben alle 5 Whiskys das gleiche created_at (Upload-Zeitpunkt). Du brauchst den Zeitpunkt, an dem der Button gedrückt wurde (lokale Handy-Zeit).
|
||||||
{AVAILABLE_TAGS_JSON}
|
|
||||||
|
|
||||||
PART 3: OUTPUT
|
Zusammenfassung für die Session-Logik:
|
||||||
Output strictly raw JSON matching the following schema (no markdown, no code blocks):
|
|
||||||
|
|
||||||
{
|
Das Datenmodell muss wissen:
|
||||||
"name": string | null,
|
|
||||||
"distillery": string | null,
|
session_start (Zeitstempel)
|
||||||
"category": string | null,
|
|
||||||
"abv": number | null,
|
session_end (Zeitstempel)
|
||||||
"age": number | null,
|
|
||||||
"vintage": string | null,
|
Innerhalb der Session: Relative Zeit ("Dram Nr. 3, +45min nach Start").
|
||||||
"bottleCode": string | null,
|
|
||||||
"whiskybaseId": string | null,
|
|
||||||
"distilled_at": string | null,
|
|
||||||
"bottled_at": string | null,
|
|
||||||
"batch_info": string | null,
|
|
||||||
"is_whisky": boolean,
|
|
||||||
"confidence": number,
|
|
||||||
"suggested_tags": string[]
|
|
||||||
}
|
|
||||||
13
flight_recorder_migration.sql
Normal file
13
flight_recorder_migration.sql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
-- Migration: Flight Recorder Support
|
||||||
|
ALTER TABLE tasting_sessions
|
||||||
|
ADD COLUMN IF NOT EXISTS started_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
ADD COLUMN IF NOT EXISTS ended_at TIMESTAMP WITH TIME ZONE;
|
||||||
|
|
||||||
|
ALTER TABLE tastings
|
||||||
|
ADD COLUMN IF NOT EXISTS tasted_at TIMESTAMP WITH TIME ZONE;
|
||||||
|
|
||||||
|
-- Backfill: Nutze created_at für bestehende Tastings
|
||||||
|
UPDATE tastings SET tasted_at = created_at WHERE tasted_at IS NULL;
|
||||||
|
|
||||||
|
-- Index für schnelleres Sortieren der Timeline
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_tastings_tasted_at ON tastings(tasted_at);
|
||||||
@@ -1,232 +1,51 @@
|
|||||||
import { createClient } from '@/lib/supabase/server';
|
'use client';
|
||||||
import { notFound } from 'next/navigation';
|
|
||||||
import Link from 'next/link';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { ChevronLeft, Calendar, Award, Droplets, MapPin, Tag, ExternalLink, Package, PlusCircle, Info } from 'lucide-react';
|
import BottleDetails from '@/components/BottleDetails';
|
||||||
import { getStorageUrl } from '@/lib/supabase';
|
import { createClient } from '@/lib/supabase/client';
|
||||||
import TastingNoteForm from '@/components/TastingNoteForm';
|
|
||||||
import TastingList from '@/components/TastingList';
|
|
||||||
import DeleteBottleButton from '@/components/DeleteBottleButton';
|
|
||||||
import EditBottleForm from '@/components/EditBottleForm';
|
|
||||||
import { validateSession } from '@/services/validate-session';
|
import { validateSession } from '@/services/validate-session';
|
||||||
|
import { useParams, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
export default async function BottlePage(props: {
|
export default function BottlePage() {
|
||||||
params: Promise<{ id: string }>,
|
const params = useParams();
|
||||||
searchParams: Promise<{ session_id?: string }>
|
const searchParams = useSearchParams();
|
||||||
}) {
|
const [sessionId, setSessionId] = useState<string | undefined>(undefined);
|
||||||
const params = await props.params;
|
const [userId, setUserId] = useState<string | undefined>(undefined);
|
||||||
const searchParams = await props.searchParams;
|
const supabase = createClient();
|
||||||
let sessionId = searchParams.session_id;
|
|
||||||
|
|
||||||
// Validate Session Age (12 hour limit)
|
const bottleId = params?.id as string;
|
||||||
if (sessionId) {
|
const rawSessionId = searchParams?.get('session_id');
|
||||||
const isValid = await validateSession(sessionId);
|
|
||||||
if (!isValid) {
|
|
||||||
sessionId = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const supabase = await createClient();
|
|
||||||
const { data: { user } } = await supabase.auth.getUser();
|
|
||||||
|
|
||||||
const { data: bottle } = await supabase
|
useEffect(() => {
|
||||||
.from('bottles')
|
const checkSession = async () => {
|
||||||
.select('*')
|
if (rawSessionId) {
|
||||||
.eq('id', params.id)
|
const isValid = await validateSession(rawSessionId);
|
||||||
.single();
|
if (isValid) {
|
||||||
|
setSessionId(rawSessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!bottle) {
|
const getAuth = async () => {
|
||||||
notFound();
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
}
|
if (user) {
|
||||||
|
setUserId(user.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const tastingsResult = await supabase
|
checkSession();
|
||||||
.from('tastings')
|
getAuth();
|
||||||
.select(`
|
}, [rawSessionId, supabase]);
|
||||||
*,
|
|
||||||
tasting_sessions (
|
|
||||||
id,
|
|
||||||
name
|
|
||||||
),
|
|
||||||
tasting_buddies (
|
|
||||||
buddies (
|
|
||||||
id,
|
|
||||||
name
|
|
||||||
)
|
|
||||||
),
|
|
||||||
tasting_tags (
|
|
||||||
tags (
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
category,
|
|
||||||
is_system_default
|
|
||||||
)
|
|
||||||
)
|
|
||||||
`)
|
|
||||||
.eq('bottle_id', params.id)
|
|
||||||
.order('created_at', { ascending: false });
|
|
||||||
|
|
||||||
let tastings = tastingsResult.data;
|
if (!bottleId) return null;
|
||||||
|
|
||||||
if (tastingsResult.error) {
|
|
||||||
console.error('Error fetching tastings with sessions:', tastingsResult.error);
|
|
||||||
// Fallback: try without session join if relationship is missing
|
|
||||||
const fallbackResult = await supabase
|
|
||||||
.from('tastings')
|
|
||||||
.select(`
|
|
||||||
*,
|
|
||||||
tasting_tags (
|
|
||||||
tags (
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
category,
|
|
||||||
is_system_default
|
|
||||||
)
|
|
||||||
)
|
|
||||||
`)
|
|
||||||
.eq('bottle_id', params.id)
|
|
||||||
.order('created_at', { ascending: false });
|
|
||||||
|
|
||||||
tastings = fallbackResult.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-zinc-50 dark:bg-black p-4 md:p-12 lg:p-24">
|
<main className="min-h-screen bg-zinc-50 dark:bg-black p-4 md:p-12 lg:p-24">
|
||||||
<div className="max-w-4xl mx-auto space-y-6 md:space-y-12">
|
<BottleDetails
|
||||||
{/* Back Button */}
|
bottleId={bottleId}
|
||||||
<Link
|
sessionId={sessionId}
|
||||||
href={`/${sessionId ? `?session_id=${sessionId}` : ''}`}
|
userId={userId}
|
||||||
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>
|
|
||||||
|
|
||||||
|
|
||||||
{/* 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">
|
|
||||||
<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">
|
|
||||||
<EditBottleForm bottle={bottle} />
|
|
||||||
<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 || []} currentUserId={user?.id} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { deleteSession } from '@/services/delete-session';
|
|||||||
import { useSession } from '@/context/SessionContext';
|
import { useSession } from '@/context/SessionContext';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { useI18n } from '@/i18n/I18nContext';
|
import { useI18n } from '@/i18n/I18nContext';
|
||||||
|
import SessionTimeline from '@/components/SessionTimeline';
|
||||||
|
import SessionABVCurve from '@/components/SessionABVCurve';
|
||||||
|
|
||||||
interface Buddy {
|
interface Buddy {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -31,12 +33,20 @@ interface Session {
|
|||||||
interface SessionTasting {
|
interface SessionTasting {
|
||||||
id: string;
|
id: string;
|
||||||
rating: number;
|
rating: number;
|
||||||
|
tasted_at: string;
|
||||||
bottles: {
|
bottles: {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
distillery: string;
|
distillery: string;
|
||||||
image_url?: string | null;
|
image_url?: string | null;
|
||||||
|
abv: number;
|
||||||
|
category?: string;
|
||||||
};
|
};
|
||||||
|
tasting_tags: {
|
||||||
|
tags: {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SessionDetailPage() {
|
export default function SessionDetailPage() {
|
||||||
@@ -84,15 +94,17 @@ export default function SessionDetailPage() {
|
|||||||
// Fetch Tastings in this session
|
// Fetch Tastings in this session
|
||||||
const { data: tastingData } = await supabase
|
const { data: tastingData } = await supabase
|
||||||
.from('tastings')
|
.from('tastings')
|
||||||
.select('id, rating, bottles(id, name, distillery, image_url)')
|
.select(`
|
||||||
|
id,
|
||||||
|
rating,
|
||||||
|
tasted_at,
|
||||||
|
bottles(id, name, distillery, image_url, abv, category),
|
||||||
|
tasting_tags(tags(name))
|
||||||
|
`)
|
||||||
.eq('session_id', id)
|
.eq('session_id', id)
|
||||||
.order('created_at', { ascending: false });
|
.order('tasted_at', { ascending: true });
|
||||||
|
|
||||||
setTastings((tastingData as any)?.map((t: any) => ({
|
setTastings((tastingData as any) || []);
|
||||||
id: t.id,
|
|
||||||
rating: t.rating,
|
|
||||||
bottles: t.bottles
|
|
||||||
})) || []);
|
|
||||||
|
|
||||||
// Fetch all buddies for the picker
|
// Fetch all buddies for the picker
|
||||||
const { data: buddies } = await supabase
|
const { data: buddies } = await supabase
|
||||||
@@ -323,6 +335,17 @@ export default function SessionDetailPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ABV Curve */}
|
||||||
|
{tastings.length > 0 && (
|
||||||
|
<SessionABVCurve
|
||||||
|
tastings={tastings.map(t => ({
|
||||||
|
id: t.id,
|
||||||
|
abv: t.bottles.abv || 40,
|
||||||
|
tasted_at: t.tasted_at
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Main Content: Bottle List */}
|
{/* Main Content: Bottle List */}
|
||||||
@@ -342,33 +365,18 @@ export default function SessionDetailPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tastings.length === 0 ? (
|
<SessionTimeline
|
||||||
<div className="text-center py-12 text-zinc-500 italic text-sm">
|
tastings={tastings.map(t => ({
|
||||||
Noch keine Flaschen in dieser Session verkostet. 🥃
|
id: t.id,
|
||||||
</div>
|
bottle_id: t.bottles.id,
|
||||||
) : (
|
bottle_name: t.bottles.name,
|
||||||
<div className="space-y-4">
|
tasted_at: t.tasted_at,
|
||||||
{tastings.map((t) => (
|
rating: t.rating,
|
||||||
<div key={t.id} className="flex items-center justify-between p-4 bg-zinc-50 dark:bg-zinc-800/50 rounded-2xl border border-zinc-100 dark:border-zinc-800">
|
tags: t.tasting_tags?.map((tg: any) => tg.tags.name) || [],
|
||||||
<div>
|
category: t.bottles.category
|
||||||
<div className="text-[9px] font-black text-amber-600 uppercase tracking-widest">{t.bottles.distillery}</div>
|
}))}
|
||||||
<div className="font-bold text-zinc-800 dark:text-zinc-100">{t.bottles.name}</div>
|
sessionStart={session.scheduled_at} // Fallback to scheduled time if no started_at
|
||||||
</div>
|
/>
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="bg-amber-100 dark:bg-amber-900/30 text-amber-700 px-3 py-1 rounded-xl text-xs font-black">
|
|
||||||
{t.rating}/100
|
|
||||||
</div>
|
|
||||||
<Link
|
|
||||||
href={`/bottles/${t.bottles.id}`}
|
|
||||||
className="p-2 text-zinc-300 hover:text-amber-600 transition-colors"
|
|
||||||
>
|
|
||||||
<ChevronRight size={20} />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
209
src/components/BottleDetails.tsx
Normal file
209
src/components/BottleDetails.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -126,6 +126,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
|||||||
if (!navigator.onLine) {
|
if (!navigator.onLine) {
|
||||||
console.log('Offline detected. Queuing image...');
|
console.log('Offline detected. Queuing image...');
|
||||||
await db.pending_scans.add({
|
await db.pending_scans.add({
|
||||||
|
temp_id: crypto.randomUUID(),
|
||||||
imageBase64: compressedBase64,
|
imageBase64: compressedBase64,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
provider: aiProvider,
|
provider: aiProvider,
|
||||||
@@ -167,6 +168,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
|
|||||||
if (isNetworkError) {
|
if (isNetworkError) {
|
||||||
console.log('Network issue detected during scan. Queuing...');
|
console.log('Network issue detected during scan. Queuing...');
|
||||||
await db.pending_scans.add({
|
await db.pending_scans.add({
|
||||||
|
temp_id: crypto.randomUUID(),
|
||||||
imageBase64: compressedBase64,
|
imageBase64: compressedBase64,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
provider: aiProvider,
|
provider: aiProvider,
|
||||||
|
|||||||
133
src/components/SessionABVCurve.tsx
Normal file
133
src/components/SessionABVCurve.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
118
src/components/SessionTimeline.tsx
Normal file
118
src/components/SessionTimeline.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { useSession } from '@/context/SessionContext';
|
|||||||
import TagSelector from './TagSelector';
|
import TagSelector from './TagSelector';
|
||||||
import { useLiveQuery } from 'dexie-react-hooks';
|
import { useLiveQuery } from 'dexie-react-hooks';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
|
import { AlertTriangle } from 'lucide-react';
|
||||||
|
|
||||||
interface Buddy {
|
interface Buddy {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -16,11 +17,13 @@ interface Buddy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface TastingNoteFormProps {
|
interface TastingNoteFormProps {
|
||||||
bottleId: string;
|
bottleId?: string; // Real ID
|
||||||
|
pendingBottleId?: string; // Temp ID for queued scans
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
|
onSuccess?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteFormProps) {
|
export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId, onSuccess }: TastingNoteFormProps) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
const [rating, setRating] = useState(85);
|
const [rating, setRating] = useState(85);
|
||||||
@@ -38,11 +41,15 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
|
|||||||
const [suggestedTags, setSuggestedTags] = useState<string[]>([]);
|
const [suggestedTags, setSuggestedTags] = useState<string[]>([]);
|
||||||
const [suggestedCustomTags, setSuggestedCustomTags] = useState<string[]>([]);
|
const [suggestedCustomTags, setSuggestedCustomTags] = useState<string[]>([]);
|
||||||
const { activeSession } = useSession();
|
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;
|
const effectiveSessionId = sessionId || activeSession?.id;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
|
if (!bottleId) return;
|
||||||
|
|
||||||
// Fetch Bottle Suggestions
|
// Fetch Bottle Suggestions
|
||||||
const { data: bottleData } = await supabase
|
const { data: bottleData } = await supabase
|
||||||
.from('bottles')
|
.from('bottles')
|
||||||
@@ -57,7 +64,7 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
|
|||||||
setSuggestedCustomTags(bottleData.suggested_custom_tags);
|
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) {
|
if (effectiveSessionId) {
|
||||||
const { data: participants } = await supabase
|
const { data: participants } = await supabase
|
||||||
.from('session_participants')
|
.from('session_participants')
|
||||||
@@ -67,13 +74,59 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
|
|||||||
if (participants) {
|
if (participants) {
|
||||||
setSelectedBuddyIds(participants.map(p => p.buddy_id));
|
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 {
|
} else {
|
||||||
setSelectedBuddyIds([]);
|
setSelectedBuddyIds([]);
|
||||||
|
setLastDramInSession(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
fetchData();
|
fetchData();
|
||||||
}, [supabase, effectiveSessionId, bottleId]);
|
}, [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) => {
|
const toggleBuddy = (id: string) => {
|
||||||
setSelectedBuddyIds(prev =>
|
setSelectedBuddyIds(prev =>
|
||||||
prev.includes(id) ? prev.filter(bid => bid !== id) : [...prev, id]
|
prev.includes(id) ? prev.filter(bid => bid !== id) : [...prev, id]
|
||||||
@@ -109,7 +162,7 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
bottle_id: bottleId,
|
bottle_id: bottleId as string,
|
||||||
session_id: effectiveSessionId,
|
session_id: effectiveSessionId,
|
||||||
rating,
|
rating,
|
||||||
nose_notes: nose,
|
nose_notes: nose,
|
||||||
@@ -121,15 +174,17 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!navigator.onLine) {
|
if (!navigator.onLine || pendingBottleId) {
|
||||||
// Save to Offline DB
|
// Save to Offline DB
|
||||||
await db.pending_tastings.add({
|
await db.pending_tastings.add({
|
||||||
bottle_id: bottleId,
|
bottle_id: bottleId,
|
||||||
|
pending_bottle_id: pendingBottleId,
|
||||||
data,
|
data,
|
||||||
tasted_at: new Date().toISOString()
|
tasted_at: new Date().toISOString()
|
||||||
});
|
});
|
||||||
clearForm();
|
clearForm();
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
onSuccess?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,6 +192,7 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
clearForm();
|
clearForm();
|
||||||
|
onSuccess?.();
|
||||||
} else {
|
} else {
|
||||||
setError(result.error || t('common.error'));
|
setError(result.error || t('common.error'));
|
||||||
}
|
}
|
||||||
@@ -161,6 +217,28 @@ export default function TastingNoteForm({ bottleId, sessionId }: TastingNoteForm
|
|||||||
</div>
|
</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="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="text-[11px] font-black text-zinc-400 uppercase tracking-widest flex items-center gap-2">
|
<label className="text-[11px] font-black text-zinc-400 uppercase tracking-widest flex items-center gap-2">
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import { analyzeBottle } from '@/services/analyze-bottle';
|
|||||||
import { saveBottle } from '@/services/save-bottle';
|
import { saveBottle } from '@/services/save-bottle';
|
||||||
import { saveTasting } from '@/services/save-tasting';
|
import { saveTasting } from '@/services/save-tasting';
|
||||||
import { createClient } from '@/lib/supabase/client';
|
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() {
|
export default function UploadQueue() {
|
||||||
const supabase = createClient();
|
const supabase = createClient();
|
||||||
@@ -15,6 +16,7 @@ export default function UploadQueue() {
|
|||||||
const [currentProgress, setCurrentProgress] = useState<{ id: string, status: string } | null>(null);
|
const [currentProgress, setCurrentProgress] = useState<{ id: string, status: string } | null>(null);
|
||||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||||
const [completedItems, setCompletedItems] = useState<{ id: string; name: string; bottleId?: string; type: 'scan' | 'tasting' }[]>([]);
|
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 pendingScans = useLiveQuery(() => db.pending_scans.toArray(), [], [] as PendingScan[]);
|
||||||
const pendingTastings = useLiveQuery(() => db.pending_tastings.toArray(), [], [] as PendingTasting[]);
|
const pendingTastings = useLiveQuery(() => db.pending_tastings.toArray(), [], [] as PendingTasting[]);
|
||||||
@@ -45,10 +47,27 @@ export default function UploadQueue() {
|
|||||||
setCurrentProgress({ id: itemId, status: 'Speichere Flasche...' });
|
setCurrentProgress({ id: itemId, status: 'Speichere Flasche...' });
|
||||||
const save = await saveBottle(bottleData, item.imageBase64, user.id);
|
const save = await saveBottle(bottleData, item.imageBase64, user.id);
|
||||||
if (save.success && save.data) {
|
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), {
|
setCompletedItems(prev => [...prev.slice(-4), {
|
||||||
id: itemId,
|
id: itemId,
|
||||||
name: bottleData.name || 'Unbekannter Whisky',
|
name: bottleData.name || 'Unbekannter Whisky',
|
||||||
bottleId: save.data.id,
|
bottleId: newBottleId,
|
||||||
type: 'scan'
|
type: 'scan'
|
||||||
}]);
|
}]);
|
||||||
await db.pending_scans.delete(item.id!);
|
await db.pending_scans.delete(item.id!);
|
||||||
@@ -66,13 +85,19 @@ export default function UploadQueue() {
|
|||||||
|
|
||||||
// 2. Sync Tastings
|
// 2. Sync Tastings
|
||||||
for (const item of pendingTastings) {
|
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}`;
|
const itemId = `tasting-${item.id}`;
|
||||||
setCurrentProgress({ id: itemId, status: 'Synchronisiere Tasting...' });
|
setCurrentProgress({ id: itemId, status: 'Synchronisiere Tasting...' });
|
||||||
try {
|
try {
|
||||||
const result = await saveTasting({
|
const result = await saveTasting({
|
||||||
...item.data,
|
...item.data,
|
||||||
is_sample: item.data.is_sample ?? false,
|
is_sample: item.data.is_sample ?? false,
|
||||||
bottle_id: item.bottle_id,
|
bottle_id: item.bottle_id as string,
|
||||||
tasted_at: item.tasted_at
|
tasted_at: item.tasted_at
|
||||||
});
|
});
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -184,26 +209,46 @@ export default function UploadQueue() {
|
|||||||
|
|
||||||
{/* Scans */}
|
{/* Scans */}
|
||||||
{pendingScans.map((item) => (
|
{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 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 gap-3">
|
<div className="flex items-center justify-between">
|
||||||
<div className="w-10 h-10 rounded-lg bg-zinc-800 overflow-hidden ring-1 ring-white/10 shrink-0">
|
<div className="flex items-center gap-3">
|
||||||
<img src={item.imageBase64} className="w-full h-full object-cover opacity-60 group-hover:opacity-100 transition-opacity" />
|
<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>
|
||||||
<div className="flex flex-col gap-0.5">
|
<div className="text-[9px] text-zinc-500 font-bold whitespace-nowrap ml-4">
|
||||||
<span className="text-[10px] font-black text-amber-500/80 uppercase tracking-widest">Magic Shot</span>
|
{new Date(item.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
<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>
|
</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' })}
|
{/* Link for adding tasting notes to pending scan */}
|
||||||
</div>
|
<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>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -215,9 +260,13 @@ export default function UploadQueue() {
|
|||||||
<div className="text-sm font-black text-amber-500">{item.data.rating}</div>
|
<div className="text-sm font-black text-amber-500">{item.data.rating}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-0.5">
|
<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">
|
<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">
|
<span className="flex items-center gap-1.5">
|
||||||
<Loader2 size={10} className="animate-spin" />
|
<Loader2 size={10} className="animate-spin" />
|
||||||
{currentProgress.status}
|
{currentProgress.status}
|
||||||
@@ -255,3 +304,4 @@ export default function UploadQueue() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
83
src/hooks/useBottleData.ts
Normal file
83
src/hooks/useBottleData.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { createClient } from '@/lib/supabase/client';
|
||||||
|
import { db, type CachedBottle, type CachedTasting } from '@/lib/db';
|
||||||
|
import { useLiveQuery } from 'dexie-react-hooks';
|
||||||
|
|
||||||
|
export function useBottleData(bottleId: string) {
|
||||||
|
const supabase = createClient();
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Live queries from Dexie
|
||||||
|
const cachedBottle = useLiveQuery(() => db.cache_bottles.get(bottleId), [bottleId]);
|
||||||
|
const cachedTastings = useLiveQuery(() =>
|
||||||
|
db.cache_tastings.where('bottle_id').equals(bottleId).sortBy('created_at'),
|
||||||
|
[bottleId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const refreshData = useCallback(async () => {
|
||||||
|
if (!navigator.onLine) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Fetch Bottle
|
||||||
|
const { data: bottle, error: bottleError } = await supabase
|
||||||
|
.from('bottles')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', bottleId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (bottleError) throw bottleError;
|
||||||
|
|
||||||
|
// 2. Fetch Tastings
|
||||||
|
const { data: tastings, error: tastingsError } = await supabase
|
||||||
|
.from('tastings')
|
||||||
|
.select(`
|
||||||
|
*,
|
||||||
|
tasting_sessions (id, name),
|
||||||
|
tasting_tags (
|
||||||
|
tags (id, name, category)
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
.eq('bottle_id', bottleId)
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
|
if (tastingsError) throw tastingsError;
|
||||||
|
|
||||||
|
// 3. Update Dexie Cache
|
||||||
|
await db.cache_bottles.put({
|
||||||
|
...bottle,
|
||||||
|
last_updated: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear old cache for this bottle and put new
|
||||||
|
await db.cache_tastings.where('bottle_id').equals(bottleId).delete();
|
||||||
|
if (tastings && tastings.length > 0) {
|
||||||
|
await db.cache_tastings.bulkAdd(tastings as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Error refreshing bottle data:', err);
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [bottleId, supabase]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshData();
|
||||||
|
}, [refreshData]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
bottle: cachedBottle,
|
||||||
|
tastings: cachedTastings ? [...cachedTastings].reverse() : [], // Dexie sorted asc, we want desc
|
||||||
|
loading: loading && !cachedBottle, // Only show loading if we have nothing at all
|
||||||
|
error,
|
||||||
|
refresh: refreshData,
|
||||||
|
isOffline: !navigator.onLine
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import Dexie, { type Table } from 'dexie';
|
|||||||
|
|
||||||
export interface PendingScan {
|
export interface PendingScan {
|
||||||
id?: number;
|
id?: number;
|
||||||
|
temp_id: string; // Used to link tasting notes before sync
|
||||||
imageBase64: string;
|
imageBase64: string;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
provider?: 'gemini' | 'nebius';
|
provider?: 'gemini' | 'nebius';
|
||||||
@@ -10,7 +11,8 @@ export interface PendingScan {
|
|||||||
|
|
||||||
export interface PendingTasting {
|
export interface PendingTasting {
|
||||||
id?: number;
|
id?: number;
|
||||||
bottle_id: string;
|
bottle_id?: string; // Real ID if already exists
|
||||||
|
pending_bottle_id?: string; // Temp ID of a pending scan
|
||||||
data: {
|
data: {
|
||||||
session_id?: string;
|
session_id?: string;
|
||||||
rating: number;
|
rating: number;
|
||||||
@@ -21,7 +23,7 @@ export interface PendingTasting {
|
|||||||
buddy_ids?: string[];
|
buddy_ids?: string[];
|
||||||
tag_ids?: string[];
|
tag_ids?: string[];
|
||||||
};
|
};
|
||||||
photo?: string; // Optional photo if taken during tasting
|
photo?: string;
|
||||||
tasted_at: string;
|
tasted_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,19 +40,53 @@ export interface CachedBuddy {
|
|||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CachedBottle {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
distillery: string;
|
||||||
|
category?: string;
|
||||||
|
abv?: number;
|
||||||
|
age?: number;
|
||||||
|
image_url?: string;
|
||||||
|
whiskybase_id?: string;
|
||||||
|
distilled_at?: string;
|
||||||
|
bottled_at?: string;
|
||||||
|
batch_info?: string;
|
||||||
|
last_updated: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CachedTasting {
|
||||||
|
id: string;
|
||||||
|
bottle_id: string;
|
||||||
|
user_id: string;
|
||||||
|
rating: number;
|
||||||
|
nose_notes?: string;
|
||||||
|
palate_notes?: string;
|
||||||
|
finish_notes?: string;
|
||||||
|
is_sample: boolean;
|
||||||
|
created_at: string;
|
||||||
|
tasted_at?: string;
|
||||||
|
tasting_sessions?: { id: string; name: string };
|
||||||
|
tasting_tags?: { tags: { id: string; name: string; category: string } }[];
|
||||||
|
}
|
||||||
|
|
||||||
export class WhiskyDexie extends Dexie {
|
export class WhiskyDexie extends Dexie {
|
||||||
pending_scans!: Table<PendingScan>;
|
pending_scans!: Table<PendingScan>;
|
||||||
pending_tastings!: Table<PendingTasting>;
|
pending_tastings!: Table<PendingTasting>;
|
||||||
cache_tags!: Table<CachedTag>;
|
cache_tags!: Table<CachedTag>;
|
||||||
cache_buddies!: Table<CachedBuddy>;
|
cache_buddies!: Table<CachedBuddy>;
|
||||||
|
cache_bottles!: Table<CachedBottle>;
|
||||||
|
cache_tastings!: Table<CachedTasting>;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super('WhiskyVault');
|
super('WhiskyVault');
|
||||||
this.version(2).stores({
|
this.version(4).stores({
|
||||||
pending_scans: '++id, timestamp, locale',
|
pending_scans: '++id, temp_id, timestamp, locale',
|
||||||
pending_tastings: '++id, bottle_id, tasted_at',
|
pending_tastings: '++id, bottle_id, pending_bottle_id, tasted_at',
|
||||||
cache_tags: 'id, category, name',
|
cache_tags: 'id, category, name',
|
||||||
cache_buddies: 'id, name'
|
cache_buddies: 'id, name',
|
||||||
|
cache_bottles: 'id, name, distillery',
|
||||||
|
cache_tastings: 'id, bottle_id, created_at'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ export async function saveTasting(rawData: TastingNoteData) {
|
|||||||
palate_notes: data.palate_notes,
|
palate_notes: data.palate_notes,
|
||||||
finish_notes: data.finish_notes,
|
finish_notes: data.finish_notes,
|
||||||
is_sample: data.is_sample || false,
|
is_sample: data.is_sample || false,
|
||||||
created_at: data.tasted_at || undefined,
|
tasted_at: data.tasted_at || new Date().toISOString(),
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
})
|
})
|
||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
|
|||||||
Reference in New Issue
Block a user