/* @component-map * App — Main container, tab navigation [app.jsx] * TastingForm — Log new wine tastings with all details [components/TastingForm.jsx] * TastingList — Browse, search, and filter past tastings [components/TastingList.jsx] * TastingDetail — Detailed view of a single tasting [components/TastingDetail.jsx] * @end-component-map */ import { useEffect, useMemo, useRef, useState } from 'react'; import { generatePDF, useAuth, useCollection, renderChart } from '@deplixo/sdk'; import { TastingForm } from './components/TastingForm.jsx'; import { TastingList } from './components/TastingList.jsx'; import { TastingDetail } from './components/TastingDetail.jsx'; function buildSharePayload(tasting, userName = 'Cellar Notes') { const wineName = tasting?.wineName?.trim() || 'Untitled Tasting'; const vintage = tasting?.vintage?.trim(); const region = tasting?.region?.trim(); const grape = tasting?.grape?.trim(); const rating = Number(tasting?.rating || 0); const date = tasting?.date || tasting?.createdAt || ''; const notes = tasting?.notes?.trim(); const aroma = tasting?.aroma?.trim(); const palate = tasting?.palate?.trim(); const finish = tasting?.finish?.trim(); const foodPairing = tasting?.foodPairing?.trim(); const parts = [ `🍷 ${wineName}`, vintage ? `Vintage: ${vintage}` : null, region ? `Region: ${region}` : null, grape ? `Grape: ${grape}` : null, rating ? `Rating: ${rating}/5` : null, date ? `Tasted: ${new Date(date).toLocaleDateString()}` : null, aroma ? `Aroma: ${aroma}` : null, palate ? `Palate: ${palate}` : null, finish ? `Finish: ${finish}` : null, foodPairing ? `Pairing: ${foodPairing}` : null, notes ? `Notes: ${notes}` : null, `Shared from ${userName} via Cellar Notes` ].filter(Boolean); return parts.join('\n'); } function formatDate(value) { if (!value) return 'Unknown'; const date = new Date(value); return Number.isNaN(date.getTime()) ? 'Unknown' : date.toLocaleDateString(); } function buildPdfTastingsReport(user, tastings, title = 'Cellar Notes — Tasting Journal') { const reportDate = new Date().toLocaleDateString(); const total = tastings.length; const avgRating = total ? (tastings.reduce((sum, tasting) => sum + Number(tasting.rating || 0), 0) / total).toFixed(2) : '0.00'; return (

{title}

Generated for {user?.name || 'Cellar Notes'} on {reportDate}

Total Tastings: {total}
Average Rating: {avgRating}/5

Journal Entries

{tastings.length > 0 ? tastings.map((tasting, index) => (

{tasting.wineName?.trim() || 'Untitled Tasting'}

Date: {formatDate(tasting.date || tasting.createdAt)}
Vintage: {tasting.vintage?.trim() || '—'}
Region: {tasting.region?.trim() || '—'}
Grape: {tasting.grape?.trim() || '—'}
Rating: {Number(tasting.rating || 0)}/5
Food Pairing: {tasting.foodPairing?.trim() || '—'}
{tasting.aroma?.trim() ?

Aroma: {tasting.aroma.trim()}

: null} {tasting.palate?.trim() ?

Palate: {tasting.palate.trim()}

: null} {tasting.finish?.trim() ?

Finish: {tasting.finish.trim()}

: null} {tasting.notes?.trim() ?

Notes: {tasting.notes.trim()}

: null}
)) :

No tastings available yet.

}
); } function buildPdfSingleTastingReport(user, tasting) { const reportDate = new Date().toLocaleDateString(); const wineName = tasting?.wineName?.trim() || 'Untitled Tasting'; return (

Cellar Notes — Tasting Detail

Generated for {user?.name || 'Cellar Notes'} on {reportDate}

Wine: {wineName}
Rating: {Number(tasting?.rating || 0)}/5

{wineName}

Date: {formatDate(tasting?.date || tasting?.createdAt)}
Vintage: {tasting?.vintage?.trim() || '—'}
Region: {tasting?.region?.trim() || '—'}
Grape: {tasting?.grape?.trim() || '—'}
Food Pairing: {tasting?.foodPairing?.trim() || '—'}
Rating: {Number(tasting?.rating || 0)}/5
{tasting?.aroma?.trim() ?

Aroma: {tasting.aroma.trim()}

: null} {tasting?.palate?.trim() ?

Palate: {tasting.palate.trim()}

: null} {tasting?.finish?.trim() ?

Finish: {tasting.finish.trim()}

: null} {tasting?.notes?.trim() ?

Notes: {tasting.notes.trim()}

: null}
); } function ChartsPanel({ tastings }) { const regionCanvasRef = useRef(null); const grapeCanvasRef = useRef(null); const trendCanvasRef = useRef(null); const [trendRange, setTrendRange] = useState('all'); const regionChartData = useMemo(() => { const regionMap = new Map(); tastings.forEach((tasting) => { const region = tasting.region?.trim() || 'Unknown'; const rating = Number(tasting.rating || 0); if (!regionMap.has(region)) regionMap.set(region, { sum: 0, count: 0 }); const current = regionMap.get(region); current.sum += rating; current.count += 1; }); return [...regionMap.entries()] .map(([label, data]) => ({ label, value: data.count ? Number((data.sum / data.count).toFixed(2)) : 0, count: data.count })) .sort((a, b) => b.count - a.count || b.value - a.value) .slice(0, 8); }, [tastings]); const grapeChartData = useMemo(() => { const grapeMap = new Map(); tastings.forEach((tasting) => { const grape = tasting.grape?.trim() || 'Unknown'; const rating = Number(tasting.rating || 0); if (!grapeMap.has(grape)) grapeMap.set(grape, { sum: 0, count: 0 }); const current = grapeMap.get(grape); current.sum += rating; current.count += 1; }); return [...grapeMap.entries()] .map(([label, data]) => ({ label, value: data.count ? Number((data.sum / data.count).toFixed(2)) : 0, count: data.count })) .sort((a, b) => b.count - a.count || b.value - a.value) .slice(0, 8); }, [tastings]); const trendChartData = useMemo(() => { const byDate = new Map(); const sorted = [...tastings].sort((a, b) => new Date(a.date || a.createdAt || 0) - new Date(b.date || b.createdAt || 0)); let filtered = sorted; if (trendRange !== 'all') { const now = new Date(); const cutoff = new Date(); if (trendRange === '90d') cutoff.setDate(now.getDate() - 90); if (trendRange === '30d') cutoff.setDate(now.getDate() - 30); if (trendRange === '12m') cutoff.setMonth(now.getMonth() - 12); filtered = sorted.filter((tasting) => new Date(tasting.date || tasting.createdAt || 0) >= cutoff); } filtered.forEach((tasting) => { const date = tasting.date || tasting.createdAt || ''; const key = date ? new Date(date).toISOString().slice(0, 10) : 'Unknown'; const rating = Number(tasting.rating || 0); if (!byDate.has(key)) byDate.set(key, { sum: 0, count: 0 }); const current = byDate.get(key); current.sum += rating; current.count += 1; }); return [...byDate.entries()] .sort((a, b) => a[0].localeCompare(b[0])) .map(([label, data]) => ({ label, value: data.count ? Number((data.sum / data.count).toFixed(2)) : 0 })); }, [tastings, trendRange]); useEffect(() => { if (regionCanvasRef.current && regionChartData.length > 0) { renderChart(regionCanvasRef.current, { type: 'bar', data: { labels: regionChartData.map((d) => d.label), datasets: [{ label: 'Avg Rating', data: regionChartData.map((d) => d.value) }] }, options: { plugins: { legend: { display: true } }, scales: { y: { suggestedMin: 0, suggestedMax: 5 } } } }); } }, [regionChartData]); useEffect(() => { if (grapeCanvasRef.current && grapeChartData.length > 0) { renderChart(grapeCanvasRef.current, { type: 'bar', data: { labels: grapeChartData.map((d) => d.label), datasets: [{ label: 'Avg Rating', data: grapeChartData.map((d) => d.value) }] }, options: { plugins: { legend: { display: true } }, scales: { y: { suggestedMin: 0, suggestedMax: 5 } } } }); } }, [grapeChartData]); useEffect(() => { if (trendCanvasRef.current && trendChartData.length > 0) { renderChart(trendCanvasRef.current, { type: 'line', data: { labels: trendChartData.map((d) => d.label), datasets: [{ label: 'Avg Rating', data: trendChartData.map((d) => d.value), tension: 0.3, fill: false }] }, options: { plugins: { legend: { display: true } }, scales: { y: { suggestedMin: 0, suggestedMax: 5 } } } }); } }, [trendChartData]); return (

Tasting Insights

See how your ratings vary by region, grape variety, and over time.

Ratings by Region

Average rating
{regionChartData.length > 0 ? :

Add tastings to see region insights.

}

Ratings by Grape Variety

Average rating
{grapeChartData.length > 0 ? :

Add tastings to see grape insights.

}

Rating Trend Over Time

Average rating per date
{trendChartData.length > 0 ? :

Add tastings to see rating trends.

}
); } function App() { const { user, loading, login, logout } = useAuth(); const { items: tastings = [], loading: tastingsLoading } = useCollection('tastings', { personal: true }); const [activeTab, setActiveTab] = useState('browse'); const [selectedTasting, setSelectedTasting] = useState(null); const [editingTasting, setEditingTasting] = useState(null); const [shareStatus, setShareStatus] = useState(''); const [pdfExporting, setPdfExporting] = useState(false); const pdfExportRef = useRef(null); const handleViewDetail = (tasting) => { setSelectedTasting(tasting); setActiveTab('detail'); }; const handleBack = () => { setSelectedTasting(null); setActiveTab('browse'); }; const handleEdit = (tasting) => { setEditingTasting(tasting); setActiveTab('log'); }; const handleSaved = () => { setEditingTasting(null); setActiveTab('browse'); }; const handleShare = async (tasting) => { const shareText = buildSharePayload(tasting, user?.name || 'Cellar Notes'); const shareUrl = `${window.location.origin}${window.location.pathname}?tasting=${encodeURIComponent(tasting?._id || tasting?.id || tasting?.wineName || '')}`; const payload = `${shareText}\n\nView link: ${shareUrl}`; try { if (navigator.share) { await navigator.share({ title: tasting?.wineName ? `Cellar Notes — ${tasting.wineName}` : 'Cellar Notes Tasting', text: payload, url: shareUrl }); setShareStatus('Shared successfully.'); return; } if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(payload); setShareStatus('Share text copied to clipboard.'); return; } const textarea = document.createElement('textarea'); textarea.value = payload; textarea.setAttribute('readonly', 'true'); textarea.style.position = 'absolute'; textarea.style.left = '-9999px'; document.body.appendChild(textarea); textarea.select(); document.execCommand('copy'); document.body.removeChild(textarea); setShareStatus('Share text copied to clipboard.'); } catch { setShareStatus('Unable to share right now.'); } window.clearTimeout(handleShare._t); handleShare._t = window.setTimeout(() => setShareStatus(''), 2500); }; const handleExportPDF = async ({ scope = 'journal' } = {}) => { if (!pdfExportRef.current) return; setPdfExporting(true); try { const filename = scope === 'detail' && selectedTasting ? `${(selectedTasting.wineName || 'tasting').replace(/[^a-z0-9-_]+/gi, '_').toLowerCase()}_detail.pdf` : 'cellar-notes-journal.pdf'; await generatePDF(pdfExportRef.current, { filename, margin: 10 }); setShareStatus('PDF exported successfully.'); } catch { setShareStatus('Unable to export PDF right now.'); } finally { setPdfExporting(false); window.clearTimeout(handleExportPDF._t); handleExportPDF._t = window.setTimeout(() => setShareStatus(''), 2500); } }; if (loading) return
Signing in...
; if (!user) { return (
🍷

Cellar Notes

Sign in with Google to securely access your personal wine journal.

); } return (
🍷

Cellar Notes

Your personal wine journal

Welcome, {user.name}
{activeTab !== 'detail' && ( )}
{shareStatus ?
{shareStatus}
: null}
{activeTab === 'browse' && (
)} {activeTab === 'log' && { setEditingTasting(null); setActiveTab('browse'); }} />} {activeTab === 'detail' && selectedTasting && (
)} {activeTab === 'insights' && }
); } ReactDOM.createRoot(document.getElementById("root")).render();