From 2e0a94e88c847a5ed8dc6ad5fa49715cd631bdfe Mon Sep 17 00:00:00 2001 From: Tyler Hoang Date: Fri, 8 May 2026 01:58:48 -0700 Subject: Initial commit — Commis personal chef app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- static/app.js | 757 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 757 insertions(+) create mode 100644 static/app.js (limited to 'static/app.js') 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 = ` +
${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); -- cgit v1.3-2-g0d8e