summaryrefslogtreecommitdiff
path: root/static/app.js
diff options
context:
space:
mode:
authorTyler Hoang <tyler@tylerhoang.xyz>2026-05-08 01:58:48 -0700
committerTyler Hoang <tyler@tylerhoang.xyz>2026-05-08 01:58:48 -0700
commit2e0a94e88c847a5ed8dc6ad5fa49715cd631bdfe (patch)
tree0c27fc5a8d8cbba60e571bb6690a13c0c0060ff4 /static/app.js
Initial commit — Commis personal chef app
AI-powered local chef tool: pantry tracking, meal logging, rotating weekly menu generation, and grocery list optimization via Ollama (llama3). FastAPI backend, SQLite, vanilla JS frontend. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'static/app.js')
-rw-r--r--static/app.js757
1 files changed, 757 insertions, 0 deletions
diff --git a/static/app.js b/static/app.js
new file mode 100644
index 0000000..678b59b
--- /dev/null
+++ b/static/app.js
@@ -0,0 +1,757 @@
+// ── 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 = `
+ <div class="toast-icon">${icon}</div>
+ <div class="toast-message">${message}</div>
+ `;
+
+ 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 `
+ <tr class="${rowClass}" data-id="${ing.id}">
+ <td><strong>${ing.name}</strong></td>
+ <td>${ing.quantity}</td>
+ <td>${ing.unit}</td>
+ <td>${ing.category || '—'}</td>
+ <td>${expiryText}</td>
+ <td>
+ <button class="btn btn-sm" onclick="editIngredient(${ing.id})">Edit</button>
+ <button class="btn btn-sm btn-danger" onclick="deleteIngredient(${ing.id})">Delete</button>
+ </td>
+ </tr>
+ `;
+ }).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 = `
+ <strong>⚠️ Expiring Soon:</strong> ${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 = `
+ <div class="stats-title">Top Meals (Last 30 Days)</div>
+ ${state.mealStats.top_meals.slice(0, 5).map(meal => {
+ const percentage = (meal.count / maxCount) * 100;
+ return `
+ <div class="stat-item">
+ <div class="stat-label">${meal.meal_name}</div>
+ <div class="stat-bar">
+ <div class="stat-bar-fill" style="width: ${percentage}%">${meal.count}x</div>
+ </div>
+ <div class="stat-count">${meal.count}</div>
+ </div>
+ `;
+ }).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 = `
+ <div class="meals-timeline">
+ ${sortedDates.map(dateKey => {
+ const meals = grouped[dateKey];
+ const dateLabel = formatDate(dateKey);
+ return `
+ <div class="meal-date-group">
+ <div class="meal-date-label">${dateLabel}</div>
+ ${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 `
+ <div class="meal-entry">
+ <div class="meal-info">
+ <div class="meal-type-badge ${badgeClass}">${meal.meal_type}</div>
+ <div class="meal-name">${meal.meal_name}</div>
+ <div class="meal-time">${timeStr}</div>
+ ${meal.notes ? `<div class="meal-notes">${meal.notes}</div>` : ''}
+ </div>
+ <button class="btn btn-sm btn-danger" onclick="deleteMeal(${meal.id})">Delete</button>
+ </div>
+ `;
+ }).join('')}
+ </div>
+ `;
+ }).join('')}
+ </div>
+ `;
+}
+
+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 = '<div class="menu-grid-header"></div>';
+ days.forEach(day => {
+ html += `<div class="menu-grid-header">${day}</div>`;
+ });
+
+ // 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 += `
+ <div class="menu-grid-cell ${warningClass}">
+ <div>
+ <div class="menu-meal-type">${typeDisplay}</div>
+ <div class="menu-meal-name">${recipeName}</div>
+ ${recipeTime ? `<div class="menu-time">${recipeTime}</div>` : ''}
+ </div>
+ ${recipeName !== '—' ? `<button class="btn btn-sm btn-primary" onclick="makeThisMeal('${recipeName.replace(/'/g, "\\'")}', '${mealType}')">Make This</button>` : ''}
+ </div>
+ `;
+ });
+ });
+
+ 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 += `<div class="grocery-section">
+ <div class="grocery-section-title">${titleCased}</div>
+ ${grouped[section].map(item => {
+ const usedIn = item.used_in ? item.used_in.join(', ') : '';
+ return `
+ <div class="grocery-item">
+ <input type="checkbox">
+ <div class="grocery-item-details">
+ <div class="grocery-item-name">${item.name}</div>
+ <div class="grocery-item-meta">
+ ${item.quantity} ${item.unit}
+ ${item.estimated_cost ? ` - $${item.estimated_cost.toFixed(2)}` : ''}
+ ${usedIn ? ` - Used in: ${usedIn}` : ''}
+ </div>
+ </div>
+ </div>
+ `;
+ }).join('')}
+ </div>`;
+ });
+
+ sectionsDiv.innerHTML = html;
+
+ // Show total
+ const total = items.reduce((sum, item) => sum + (item.estimated_cost || 0), 0);
+ if (total > 0) {
+ totalDiv.innerHTML = `
+ <div class="grocery-total-label">Estimated Total</div>
+ <div class="grocery-total-amount">$${total.toFixed(2)}</div>
+ `;
+ 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);