- BottleGrid: Implement blurred backdrop effect for bottle cards - Cascade OCR: TextDetector → RegEx → Fuzzy Match → window.ai pipeline - Smart Scan: Native OCR for Android, Live Text fallback for iOS - OCR Dashboard: Admin page at /admin/ocr-logs with stats and scan history - Features: Add feature flags in src/config/features.ts - SQL: Add ocr_logs table migration - Services: Update analyze-bottle to use OpenRouter, add save-ocr-log
140 lines
6.8 KiB
TypeScript
140 lines
6.8 KiB
TypeScript
'use client';
|
|
|
|
import React from 'react';
|
|
import { Activity, AlertCircle, CheckCircle, Zap, TrendingUp } from 'lucide-react';
|
|
import { Area, AreaChart, ResponsiveContainer, Tooltip, XAxis, YAxis, CartesianGrid } from 'recharts';
|
|
|
|
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-8 bg-zinc-900 rounded-3xl border border-dashed border-zinc-800 text-center">
|
|
<Activity size={32} className="mx-auto text-zinc-700 mb-3" />
|
|
<p className="text-[10px] text-zinc-500 font-bold uppercase tracking-widest leading-relaxed">
|
|
Kurve wird ab 2 Drams berechnet
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const data = [...tastings]
|
|
.sort((a, b) => new Date(a.tasted_at).getTime() - new Date(b.tasted_at).getTime())
|
|
.map((t: ABVTasting, i: number) => ({
|
|
name: `Dram ${i + 1}`,
|
|
abv: t.abv,
|
|
timestamp: new Date(t.tasted_at).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }),
|
|
id: t.id
|
|
}));
|
|
|
|
const hasBigJump = tastings.some((t: ABVTasting, i: number) => i > 0 && Math.abs(t.abv - tastings[i - 1].abv) > 10);
|
|
const avgAbv = (tastings.reduce((acc: number, t: ABVTasting) => acc + t.abv, 0) / tastings.length).toFixed(1);
|
|
|
|
return (
|
|
<div className="bg-zinc-900 rounded-3xl p-6 border border-white/5 shadow-2xl relative group overflow-hidden">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between mb-8">
|
|
<div className="flex items-center gap-3">
|
|
<div className="p-2 bg-orange-500/10 rounded-xl">
|
|
<TrendingUp size={18} className="text-orange-500" />
|
|
</div>
|
|
<div>
|
|
<h4 className="text-[10px] font-black text-zinc-500 uppercase tracking-widest leading-none mb-1">ABV Progression</h4>
|
|
<p className="text-[8px] text-zinc-600 font-bold uppercase tracking-tighter">Alcohol By Volume Intensity</p>
|
|
</div>
|
|
</div>
|
|
{hasBigJump && (
|
|
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-red-500/10 border border-red-500/20 rounded-full">
|
|
<AlertCircle size={10} className="text-red-500" />
|
|
<span className="text-[8px] font-black text-red-500 uppercase tracking-widest">Spike Alert</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Chart Container */}
|
|
<div className="h-[180px] w-full -ml-4">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<AreaChart data={data}>
|
|
<defs>
|
|
<linearGradient id="abvGradient" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor="#ea580c" stopOpacity={0.3} />
|
|
<stop offset="95%" stopColor="#ea580c" stopOpacity={0} />
|
|
</linearGradient>
|
|
</defs>
|
|
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#ffffff05" />
|
|
<XAxis
|
|
dataKey="name"
|
|
hide
|
|
/>
|
|
<YAxis
|
|
domain={['dataMin - 5', 'dataMax + 5']}
|
|
hide
|
|
/>
|
|
<Tooltip
|
|
content={({ active, payload }) => {
|
|
if (active && payload && payload.length) {
|
|
return (
|
|
<div className="bg-zinc-950 border border-white/10 p-3 rounded-2xl shadow-2xl backdrop-blur-xl">
|
|
<p className="text-[10px] font-black text-zinc-500 uppercase tracking-widest mb-1">
|
|
{payload[0].payload.name} • {payload[0].payload.timestamp}
|
|
</p>
|
|
<p className="text-xl font-black text-white">
|
|
{payload[0].value}% <span className="text-[10px] text-zinc-500">ABV</span>
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
return null;
|
|
}}
|
|
/>
|
|
<Area
|
|
type="monotone"
|
|
dataKey="abv"
|
|
stroke="#ea580c"
|
|
strokeWidth={3}
|
|
fillOpacity={1}
|
|
fill="url(#abvGradient)"
|
|
animationDuration={1500}
|
|
/>
|
|
</AreaChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
|
|
{/* Stats Footer */}
|
|
<div className="mt-6 grid grid-cols-2 gap-4 border-t border-white/5 pt-6">
|
|
<div className="flex flex-col gap-1">
|
|
<span className="text-[9px] font-black text-zinc-500 uppercase tracking-[0.2em]">Ø Intensity</span>
|
|
<div className="flex items-baseline gap-1">
|
|
<span className="text-2xl font-black text-white tracking-tighter">{avgAbv}</span>
|
|
<span className="text-[10px] font-bold text-zinc-500 uppercase">%</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col items-end gap-1">
|
|
<span className="text-[9px] font-black text-zinc-500 uppercase tracking-[0.2em]">Flow State</span>
|
|
<div className="flex items-center gap-2">
|
|
{hasBigJump ? (
|
|
<>
|
|
<Zap size={14} className="text-red-500" />
|
|
<span className="text-[10px] font-black uppercase tracking-widest text-red-500">Aggressive</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<CheckCircle size={14} className="text-green-500" />
|
|
<span className="text-[10px] font-black uppercase tracking-widest text-green-500">Smooth</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|