import { useEffect, useMemo, useRef, useState } from 'react'; import { useAuth, useCollection, renderChart } from '@deplixo/sdk'; import { RecipeList } from './components/RecipeList.jsx'; import { RecipeEditor } from './components/RecipeEditor.jsx'; import { CostComparison } from './components/CostComparison.jsx'; function MiniBarChart({ data, label, emptyMessage = 'No data available.' }) { const canvasRef = useRef(null); useEffect(() => { if (!canvasRef.current || !Array.isArray(data) || data.length === 0) return; renderChart(canvasRef.current, { type: 'bar', data: { labels: data.map(d => d.label), datasets: [ { label, data: data.map(d => d.value), }, ], }, }); }, [data, label]); if (!Array.isArray(data) || data.length === 0) { return

{emptyMessage}

; } return ; } function App() { const { user, loading, logout } = useAuth(); const [activeTab, setActiveTab] = useState('recipes'); const [editingRecipe, setEditingRecipe] = useState(null); const [ingredientSearch, setIngredientSearch] = useState(''); const [priceDraft, setPriceDraft] = useState({ name: '', unit: 'unit', price: '', notes: '' }); const [refreshTick, setRefreshTick] = useState(0); const pricesCollectionName = useMemo(() => { const key = user?.id || user?.email || 'unknown'; return `ingredient_prices_${key}`; }, [user?.id, user?.email]); const { items: ingredientPrices, loading: pricesLoading, add: addPrice, update: updatePrice, remove: removePrice, search: searchPrices, } = useCollection(pricesCollectionName, { personal: true }); const tabs = [ { id: 'recipes', label: '๐Ÿ“– My Recipes', icon: '๐Ÿ“–' }, { id: 'new', label: 'โœš New Recipe', icon: 'โœš' }, { id: 'compare', label: 'โš– Compare', icon: 'โš–' }, { id: 'visualize', label: '๐Ÿ“Š Visualize', icon: '๐Ÿ“Š' }, { id: 'prices', label: '๐Ÿงพ Ingredient Prices', icon: '๐Ÿงพ' }, ]; useEffect(() => { if (user && activeTab === 'recipes' && !editingRecipe) { const timer = setTimeout(() => setRefreshTick(t => t + 1), 5 * 60 * 1000); return () => clearTimeout(timer); } }, [user, activeTab, editingRecipe, refreshTick]); useEffect(() => { if (!user) return; const interval = setInterval(() => setRefreshTick(t => t + 1), 10 * 60 * 1000); return () => clearInterval(interval); }, [user]); const normalizedIngredientPrices = useMemo(() => { const items = Array.isArray(ingredientPrices) ? ingredientPrices : []; return [...items].sort((a, b) => { const aTime = new Date(a.updatedAt || a.createdAt || 0).getTime(); const bTime = new Date(b.updatedAt || b.createdAt || 0).getTime(); return bTime - aTime; }); }, [ingredientPrices, refreshTick]); const filteredIngredientPrices = useMemo(() => { const q = ingredientSearch.trim().toLowerCase(); if (!q) return normalizedIngredientPrices; return normalizedIngredientPrices.filter(item => { const name = String(item.name || '').toLowerCase(); const unit = String(item.unit || '').toLowerCase(); const notes = String(item.notes || '').toLowerCase(); return name.includes(q) || unit.includes(q) || notes.includes(q); }); }, [normalizedIngredientPrices, ingredientSearch]); const recipeCostChartData = useMemo(() => { const recipes = Array.isArray(searchPrices?.recipes) ? searchPrices.recipes : []; return recipes .map(recipe => ({ label: recipe.name || 'Untitled', value: Number(recipe.totalCost ?? recipe.cost ?? 0), })) .sort((a, b) => b.value - a.value) .slice(0, 12); }, [searchPrices]); const ingredientTrendData = useMemo(() => { const grouped = new Map(); for (const item of normalizedIngredientPrices) { const key = `${String(item.name || '').trim().toLowerCase()}__${String(item.unit || '').trim().toLowerCase()}`; if (!grouped.has(key)) grouped.set(key, []); grouped.get(key).push(item); } return [...grouped.entries()].map(([key, items]) => { const sorted = [...items].sort((a, b) => { const aTime = new Date(a.updatedAt || a.createdAt || 0).getTime(); const bTime = new Date(b.updatedAt || b.createdAt || 0).getTime(); return aTime - bTime; }); const latest = sorted[sorted.length - 1] || {}; return { key, name: latest.name || 'Ingredient', unit: latest.unit || 'unit', data: sorted.map((item, index) => ({ label: new Date(item.updatedAt || item.createdAt || Date.now()).toLocaleDateString(), value: Number(item.price || 0), id: item.id || `${key}_${index}`, })), }; }).slice(0, 6); }, [normalizedIngredientPrices]); const handleEdit = (recipe) => { setEditingRecipe(recipe); setActiveTab('new'); }; const handleSaved = () => { setEditingRecipe(null); setActiveTab('recipes'); }; const handleNewTab = () => { if (activeTab === 'new' && !editingRecipe) return; setEditingRecipe(null); setActiveTab('new'); }; const handlePriceSubmit = async (e) => { e.preventDefault(); const name = priceDraft.name.trim(); const unit = priceDraft.unit.trim() || 'unit'; const price = Number(priceDraft.price); if (!name || !Number.isFinite(price)) return; const existing = normalizedIngredientPrices.find(item => String(item.name || '').toLowerCase() === name.toLowerCase() && String(item.unit || '').toLowerCase() === unit.toLowerCase()); const payload = { name, unit, price, notes: priceDraft.notes.trim(), updatedAt: new Date().toISOString(), refreshedAt: new Date().toISOString(), }; if (existing) { await updatePrice(existing.id, payload); } else { await addPrice({ ...payload, createdAt: new Date().toISOString() }); } setPriceDraft({ name: '', unit: 'unit', price: '', notes: '' }); }; const handleRefreshPrices = async () => { const now = new Date().toISOString(); for (const item of normalizedIngredientPrices) { await updatePrice(item.id, { ...item, refreshedAt: now, updatedAt: now }); } setRefreshTick(t => t + 1); }; if (loading) { return
Signing in...
; } if (!user) { return (

Recipe Cost Calculator

Sign in with Google to access your private recipe and ingredient database

Protected recipe workspace

Your saved recipes and ingredient price database are private to your account. Sign in to create, compare, and manage your costs.

Deplixo will handle Google sign-in for you.

); } return (
{user.avatar ? {user.name} :
{user.name?.charAt(0) || 'U'}
}
Welcome, {user.name} {user.email}

Recipe Cost Calculator

Track ingredient costs ยท Calculate per-serving prices

{activeTab === 'recipes' && } {activeTab === 'new' && } {activeTab === 'compare' && } {activeTab === 'visualize' && (

Recipe cost comparison

Bar chart of recipe total costs for quick side-by-side comparison.

Ingredient price trends

Trend charts use your stored ingredient price history over time.

{ingredientTrendData.length === 0 ? (
๐Ÿ“ˆ

No ingredient history yet

Add and update ingredient prices to see trend charts over time.

) : ingredientTrendData.map(series => (

{series.name}

{series.unit}
))}
)} {activeTab === 'prices' && (

Personal ingredient price database

Stored privately per user. Add prices once and reuse them across recipes.

setPriceDraft(d => ({ ...d, name: e.target.value }))} placeholder="e.g. Olive oil" />
setPriceDraft(d => ({ ...d, unit: e.target.value }))} placeholder="e.g. liter, kg, unit" />
setPriceDraft(d => ({ ...d, price: e.target.value }))} placeholder="0.00" />
setPriceDraft(d => ({ ...d, notes: e.target.value }))} placeholder="Optional supplier, pack size, etc." />
setIngredientSearch(e.target.value)} placeholder="Search ingredient prices" />
{filteredIngredientPrices.length === 0 ? (
๐Ÿงพ

No ingredient prices yet

Add your first personal price to reuse it in recipes.

) : filteredIngredientPrices.map(item => (

{item.name}

{item.unit}
Price ${Number(item.price || 0).toFixed(2)}
Updated {item.updatedAt ? new Date(item.updatedAt).toLocaleDateString() : 'โ€”'}

{item.notes || 'No notes saved.'}

))}
)}
); } ReactDOM.createRoot(document.getElementById('root')).render();