// ── State ────────────────────────────────────────────────────────────────── const state = { activeTab: 'pantry', pantry: [], meals: [], mealStats: null, currentMenu: null, currentGrocery: null, ollamaStatus: 'unknown', currentGroceryId: null, editingIngredientId: null, }; // ── API Utilities ────────────────────────────────────────────────────────── async function api(method, path, body = null) { const opts = { method, headers: { 'Content-Type': 'application/json' } }; if (body) opts.body = JSON.stringify(body); try { const res = await fetch(`/api${path}`, opts); if (!res.ok) { const err = await res.json().catch(() => ({ detail: res.statusText })); throw new Error(err.detail || 'Request failed'); } if (res.status === 204) return null; return res.json(); } catch (err) { throw err; } } // ── Toast Notifications ─────────────────────────────────────────────────── function showToast(message, type = 'success') { const container = document.getElementById('toast-container'); const toast = document.createElement('div'); toast.className = `toast toast-${type}`; const icon = type === 'success' ? '✓' : '✕'; toast.innerHTML = `
${icon}
${message}
`; container.appendChild(toast); setTimeout(() => { toast.classList.add('exiting'); setTimeout(() => toast.remove(), 300); }, 4000); } // ── Formatting Utilities ─────────────────────────────────────────────────── function formatDate(isoStr) { if (!isoStr) return ''; const date = new Date(isoStr + 'T00:00:00Z'); const today = new Date(); const yesterday = new Date(today); yesterday.setDate(yesterday.getDate() - 1); const dateStr = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); const dateOnly = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); const todayStr = today.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); const yesterdayStr = yesterday.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); if (dateOnly === todayStr) return 'Today'; if (dateOnly === yesterdayStr) return 'Yesterday'; return dateStr; } function formatDateTime(isoStr) { if (!isoStr) return ''; const date = new Date(isoStr); return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ', ' + date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); } function daysUntilExpiry(expiryDateStr) { if (!expiryDateStr) return null; const expiry = new Date(expiryDateStr + 'T00:00:00Z'); const today = new Date(); today.setUTCHours(0, 0, 0, 0); const days = Math.floor((expiry - today) / (1000 * 60 * 60 * 24)); return days; } // ── Tab Switching ────────────────────────────────────────────────────────── function switchTab(tabName) { state.activeTab = tabName; // Update button states document.querySelectorAll('.tab-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.tab === tabName); }); // Update content visibility document.querySelectorAll('.tab-content').forEach(content => { content.classList.toggle('hidden', content.id !== `tab-${tabName}`); content.classList.toggle('active', content.id === `tab-${tabName}`); }); // Load tab content if (tabName === 'pantry') loadPantry(); else if (tabName === 'meals') loadMeals(); else if (tabName === 'menu') loadMenu(); else if (tabName === 'grocery') loadGrocery(); } // ── Pantry Tab ───────────────────────────────────────────────────────────── async function loadPantry() { try { state.pantry = await api('GET', '/pantry'); renderPantryTable(); updateExpiryWarning(); } catch (err) { showToast(err.message, 'error'); } } function renderPantryTable() { const tbody = document.getElementById('pantry-tbody'); const emptyState = document.getElementById('pantry-empty'); const table = document.getElementById('pantry-table'); if (state.pantry.length === 0) { tbody.innerHTML = ''; table.classList.add('hidden'); emptyState.classList.remove('hidden'); return; } table.classList.remove('hidden'); emptyState.classList.add('hidden'); tbody.innerHTML = state.pantry.map(ing => { const days = daysUntilExpiry(ing.expiry_date); let rowClass = ''; if (days !== null && days < 2) rowClass = 'expiry-danger'; else if (days !== null && days < 7) rowClass = 'expiry-warn'; const expiryText = ing.expiry_date ? formatDate(ing.expiry_date) : '—'; return ` ${ing.name} ${ing.quantity} ${ing.unit} ${ing.category || '—'} ${expiryText} `; }).join(''); } function updateExpiryWarning() { const banner = document.getElementById('expiry-warning-banner'); const expiring = state.pantry.filter(ing => { const days = daysUntilExpiry(ing.expiry_date); return days !== null && days < 3 && days >= 0; }); if (expiring.length === 0) { banner.classList.add('hidden'); return; } banner.classList.remove('hidden'); banner.innerHTML = ` ⚠️ Expiring Soon: ${expiring.map(ing => `${ing.name} (${formatDate(ing.expiry_date)})`).join(', ')} `; } async function addIngredient() { const name = document.getElementById('ing-name').value.trim(); const qty = parseFloat(document.getElementById('ing-qty').value); const unit = document.getElementById('ing-unit').value.trim(); const category = document.getElementById('ing-category').value; const expiry = document.getElementById('ing-expiry').value; if (!name) { showToast('Please enter ingredient name', 'error'); return; } try { await api('POST', '/pantry', { name, quantity: qty || 0, unit, category: category || null, expiry_date: expiry || null, }); document.getElementById('add-ingredient-form').classList.add('hidden'); document.getElementById('ing-name').value = ''; document.getElementById('ing-qty').value = ''; document.getElementById('ing-unit').value = ''; document.getElementById('ing-category').value = ''; document.getElementById('ing-expiry').value = ''; await loadPantry(); showToast('Ingredient added!'); } catch (err) { showToast(err.message, 'error'); } } function editIngredient(id) { const ing = state.pantry.find(i => i.id === id); if (!ing) return; state.editingIngredientId = id; document.getElementById('ing-name').value = ing.name; document.getElementById('ing-qty').value = ing.quantity || ''; document.getElementById('ing-unit').value = ing.unit || ''; document.getElementById('ing-category').value = ing.category || ''; document.getElementById('ing-expiry').value = ing.expiry_date || ''; document.getElementById('add-ingredient-form').classList.remove('hidden'); document.getElementById('btn-save-ingredient').textContent = 'Update'; } async function deleteIngredient(id) { if (!confirm('Delete this ingredient?')) return; try { await api('DELETE', `/pantry/${id}`); await loadPantry(); showToast('Ingredient deleted'); } catch (err) { showToast(err.message, 'error'); } } // ── Meals Tab ────────────────────────────────────────────────────────────── async function loadMeals() { try { state.meals = await api('GET', '/meals?days=30'); state.mealStats = await api('GET', '/meals/stats'); renderMealsStats(); renderMealsTimeline(); } catch (err) { showToast(err.message, 'error'); } } function renderMealsStats() { const statsDiv = document.getElementById('meals-stats'); if (!state.mealStats || !state.mealStats.top_meals || state.mealStats.top_meals.length === 0) { statsDiv.innerHTML = ''; return; } const maxCount = Math.max(...state.mealStats.top_meals.map(m => m.count)); statsDiv.innerHTML = `
Top Meals (Last 30 Days)
${state.mealStats.top_meals.slice(0, 5).map(meal => { const percentage = (meal.count / maxCount) * 100; return `
${meal.meal_name}
${meal.count}x
${meal.count}
`; }).join('')} `; } function renderMealsTimeline() { const timelineDiv = document.getElementById('meals-timeline'); const emptyState = document.getElementById('meals-empty'); if (state.meals.length === 0) { timelineDiv.innerHTML = ''; emptyState.classList.remove('hidden'); return; } emptyState.classList.add('hidden'); // Group meals by date const grouped = {}; state.meals.forEach(meal => { const dateKey = meal.eaten_at.split('T')[0]; if (!grouped[dateKey]) grouped[dateKey] = []; grouped[dateKey].push(meal); }); // Sort dates descending const sortedDates = Object.keys(grouped).sort().reverse(); timelineDiv.innerHTML = `
${sortedDates.map(dateKey => { const meals = grouped[dateKey]; const dateLabel = formatDate(dateKey); return `
${dateLabel}
${meals.map(meal => { const badgeClass = `meal-type-${meal.meal_type}`; const timeStr = new Date(meal.eaten_at).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }); return `
${meal.meal_type}
${meal.meal_name}
${timeStr}
${meal.notes ? `
${meal.notes}
` : ''}
`; }).join('')}
`; }).join('')}
`; } async function logMeal() { const mealName = document.getElementById('meal-name').value.trim(); const mealType = document.getElementById('meal-type').value; const eatenAt = document.getElementById('meal-datetime').value; const notes = document.getElementById('meal-notes').value.trim(); const servings = parseInt(document.getElementById('meal-servings').value) || 1; if (!mealName || !eatenAt) { showToast('Please fill meal name and date/time', 'error'); return; } try { await api('POST', '/meals', { meal_name: mealName, meal_type: mealType, eaten_at: eatenAt, notes: notes || null, servings, }); document.getElementById('log-meal-form').classList.add('hidden'); document.getElementById('meal-name').value = ''; document.getElementById('meal-notes').value = ''; document.getElementById('meal-servings').value = '1'; await loadMeals(); showToast('Meal logged!'); } catch (err) { showToast(err.message, 'error'); } } async function deleteMeal(id) { if (!confirm('Delete this meal log?')) return; try { await api('DELETE', `/meals/${id}`); await loadMeals(); showToast('Meal deleted'); } catch (err) { showToast(err.message, 'error'); } } // ── Menu Tab ─────────────────────────────────────────────────────────────── async function loadMenu() { try { const res = await api('GET', '/menus/current').catch(err => { if (err.message.includes('404')) return null; throw err; }); if (!res) { document.getElementById('menu-grid').innerHTML = ''; document.getElementById('menu-empty').classList.remove('hidden'); document.getElementById('menu-notes').classList.add('hidden'); return; } state.currentMenu = res; document.getElementById('menu-empty').classList.add('hidden'); if (res.notes) { document.getElementById('menu-notes').textContent = res.notes; document.getElementById('menu-notes').classList.remove('hidden'); } renderMenuGrid(); } catch (err) { showToast(err.message, 'error'); } } function renderMenuGrid() { const grid = document.getElementById('menu-grid'); if (!state.currentMenu) return; const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; const mealTypes = ['breakfast', 'lunch', 'dinner']; let plan = state.currentMenu.plan; if (typeof plan === 'string') { try { plan = JSON.parse(plan); } catch { plan = {}; } } let weekPlan = state.currentMenu.week_plan; if (typeof weekPlan === 'string') { try { weekPlan = JSON.parse(weekPlan); } catch { weekPlan = {}; } } // Header row let html = ''; days.forEach(day => { html += ``; }); // Meal type rows mealTypes.forEach(mealType => { days.forEach(day => { const dayKey = day.toLowerCase(); const dayPlan = plan[dayKey] || {}; const recipeId = dayPlan[mealType]; let recipeName = '—'; let recipeTime = ''; let repeatWarning = false; if (weekPlan && weekPlan[dayKey] && weekPlan[dayKey][mealType]) { const recipe = weekPlan[dayKey][mealType]; recipeName = recipe.name || recipe.recipe_name || '—'; recipeTime = recipe.time_minutes ? `${recipe.time_minutes} min` : ''; repeatWarning = recipe.repeat_warning || false; } const typeDisplay = mealType.charAt(0).toUpperCase() + mealType.slice(1); const warningClass = repeatWarning ? 'repeat-warning' : ''; html += ` `; }); }); grid.innerHTML = html; } async function generateMenu() { const btn = document.getElementById('btn-generate-menu'); const spinner = document.getElementById('menu-spinner'); btn.disabled = true; spinner.classList.remove('hidden'); try { const res = await api('POST', '/menus/generate', {}); state.currentMenu = res; document.getElementById('menu-empty').classList.add('hidden'); if (res.notes) { document.getElementById('menu-notes').textContent = res.notes; document.getElementById('menu-notes').classList.remove('hidden'); } renderMenuGrid(); showToast('Menu generated!'); } catch (err) { if (err.message.includes('503')) { showToast('Ollama is offline or not responding', 'error'); } else { showToast(err.message, 'error'); } } finally { btn.disabled = false; spinner.classList.add('hidden'); } } async function makeThisMeal(mealName, mealType) { try { const now = new Date(); await api('POST', '/meals', { meal_name: mealName, meal_type: mealType, eaten_at: now.toISOString(), notes: null, servings: 1, }); showToast(`Logged: ${mealName}`); } catch (err) { showToast(err.message, 'error'); } } // ── Grocery Tab ──────────────────────────────────────────────────────────── async function loadGrocery() { try { const res = await api('GET', '/grocery/current').catch(err => { if (err.message.includes('404')) return null; throw err; }); if (!res) { document.getElementById('grocery-sections').innerHTML = ''; document.getElementById('grocery-total').classList.add('hidden'); document.getElementById('grocery-empty').classList.remove('hidden'); document.getElementById('btn-mark-purchased').classList.add('hidden'); return; } state.currentGrocery = res; state.currentGroceryId = res.id; document.getElementById('grocery-empty').classList.add('hidden'); if (!res.is_purchased) { document.getElementById('btn-mark-purchased').classList.remove('hidden'); } else { document.getElementById('btn-mark-purchased').classList.add('hidden'); } renderGroceryList(); } catch (err) { showToast(err.message, 'error'); } } function renderGroceryList() { const sectionsDiv = document.getElementById('grocery-sections'); const totalDiv = document.getElementById('grocery-total'); if (!state.currentGrocery) return; let items = state.currentGrocery.items; if (typeof items === 'string') { try { items = JSON.parse(items); } catch { items = []; } } if (!Array.isArray(items)) items = []; // Group by store_section const grouped = {}; items.forEach(item => { const section = item.store_section || 'Other'; if (!grouped[section]) grouped[section] = []; grouped[section].push(item); }); // Render sections let html = ''; Object.keys(grouped).sort().forEach(section => { const titleCased = section.charAt(0).toUpperCase() + section.slice(1); html += `
${titleCased}
${grouped[section].map(item => { const usedIn = item.used_in ? item.used_in.join(', ') : ''; return `
${item.name}
${item.quantity} ${item.unit} ${item.estimated_cost ? ` - $${item.estimated_cost.toFixed(2)}` : ''} ${usedIn ? ` - Used in: ${usedIn}` : ''}
`; }).join('')}
`; }); sectionsDiv.innerHTML = html; // Show total const total = items.reduce((sum, item) => sum + (item.estimated_cost || 0), 0); if (total > 0) { totalDiv.innerHTML = `
Estimated Total
$${total.toFixed(2)}
`; totalDiv.classList.remove('hidden'); } } async function generateGrocery() { const btn = document.getElementById('btn-generate-grocery'); const spinner = document.getElementById('grocery-spinner'); btn.disabled = true; spinner.classList.remove('hidden'); try { const res = await api('POST', '/grocery/generate', {}); state.currentGrocery = res; state.currentGroceryId = res.id; document.getElementById('grocery-empty').classList.add('hidden'); document.getElementById('btn-mark-purchased').classList.remove('hidden'); renderGroceryList(); showToast('Grocery list generated!'); } catch (err) { if (err.message.includes('not found') || err.message.includes('No menu')) { showToast('Generate a weekly menu first', 'error'); } else if (err.message.includes('503')) { showToast('Ollama is offline or not responding', 'error'); } else { showToast(err.message, 'error'); } } finally { btn.disabled = false; spinner.classList.add('hidden'); } } async function markPurchased() { try { await api('PUT', `/grocery/${state.currentGroceryId}/purchased`, {}); await loadPantry(); await loadGrocery(); showToast('Pantry updated!'); } catch (err) { showToast(err.message, 'error'); } } // ── Initialization ───────────────────────────────────────────────────────── async function init() { // Check Ollama status try { const health = await api('GET', '/health'); const badge = document.getElementById('ollama-status'); if (health.ollama === 'connected') { badge.textContent = `✓ ${health.model}`; badge.className = 'status-badge status-ok'; } else { badge.textContent = '✗ Ollama offline'; badge.className = 'status-badge status-error'; } state.ollamaStatus = health.ollama; } catch { document.getElementById('ollama-status').textContent = '✗ Ollama offline'; document.getElementById('ollama-status').className = 'status-badge status-error'; } // Set up tab switching document.querySelectorAll('.tab-btn').forEach(btn => { btn.addEventListener('click', () => switchTab(btn.dataset.tab)); }); // Set up pantry form document.getElementById('btn-add-ingredient').addEventListener('click', () => { state.editingIngredientId = null; document.getElementById('ing-name').value = ''; document.getElementById('ing-qty').value = ''; document.getElementById('ing-unit').value = ''; document.getElementById('ing-category').value = ''; document.getElementById('ing-expiry').value = ''; document.getElementById('btn-save-ingredient').textContent = 'Save'; document.getElementById('add-ingredient-form').classList.remove('hidden'); }); document.getElementById('btn-cancel-ingredient').addEventListener('click', () => { document.getElementById('add-ingredient-form').classList.add('hidden'); }); document.getElementById('btn-save-ingredient').addEventListener('click', async () => { if (state.editingIngredientId) { const ing = state.pantry.find(i => i.id === state.editingIngredientId); if (!ing) return; const name = document.getElementById('ing-name').value.trim(); const qty = parseFloat(document.getElementById('ing-qty').value); const unit = document.getElementById('ing-unit').value.trim(); const category = document.getElementById('ing-category').value; const expiry = document.getElementById('ing-expiry').value; if (!name) { showToast('Please enter ingredient name', 'error'); return; } try { await api('PUT', `/pantry/${state.editingIngredientId}`, { name, quantity: qty || 0, unit, category: category || null, expiry_date: expiry || null, }); document.getElementById('add-ingredient-form').classList.add('hidden'); await loadPantry(); showToast('Ingredient updated!'); } catch (err) { showToast(err.message, 'error'); } } else { addIngredient(); } }); // Set up meals form document.getElementById('btn-log-meal').addEventListener('click', () => { const now = new Date(); document.getElementById('meal-datetime').value = now.toISOString().slice(0, 16); document.getElementById('meal-name').value = ''; document.getElementById('meal-type').value = 'dinner'; document.getElementById('meal-notes').value = ''; document.getElementById('meal-servings').value = '1'; document.getElementById('log-meal-form').classList.remove('hidden'); }); document.getElementById('btn-cancel-meal').addEventListener('click', () => { document.getElementById('log-meal-form').classList.add('hidden'); }); document.getElementById('btn-save-meal').addEventListener('click', logMeal); // Set up menu form document.getElementById('btn-generate-menu').addEventListener('click', generateMenu); // Set up grocery form document.getElementById('btn-generate-grocery').addEventListener('click', generateGrocery); document.getElementById('btn-mark-purchased').addEventListener('click', markPurchased); // Load initial tab await loadPantry(); } document.addEventListener('DOMContentLoaded', init);