/* @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 (
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 (
{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 (
{activeTab !== 'detail' && (
)}
{shareStatus ? {shareStatus}
: null}
{activeTab === 'browse' && (
)}
{activeTab === 'log' &&
{ setEditingTasting(null); setActiveTab('browse'); }} />}
{activeTab === 'detail' && selectedTasting && (
)}
{activeTab === 'insights' && }
);
}
ReactDOM.createRoot(document.getElementById("root")).render();