From 3de7c5eed5ba262abf0d746211e33800db6d66df Mon Sep 17 00:00:00 2001 From: Tyler Hoang Date: Fri, 8 May 2026 03:24:36 -0700 Subject: Add recipe suggestions, chat tab, and major workflow improvements - Replace 7-day grid menu with browsable recipe suggestion cards (swap, remove, make this) - Add Chat tab: conversational AI with full pantry/menu/grocery context - Grocery list works without a menu (pantry-only mode) - Individual grocery checkboxes auto-add items to pantry - Swap modal with optional preference input - User notes textarea on menu and grocery generation - Clear button for menu and grocery list - LLM notes/summary displayed after generation, persisted to DB - Favicon linked in HTML - Category dropdown styled for dark theme - System prompt configurable via SYSTEM_PROMPT in .env - Fix startup error (ollama_timeout default), DB migration for menu_plans.notes - Simplify: batch N+1 queries, extract _current_monday(), merge chat sync fns, asyncio.get_running_loop(), fix currentGroceryId bug, cap chat history Co-Authored-By: Claude Sonnet 4.6 --- static/app.js | 335 ++++++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 267 insertions(+), 68 deletions(-) (limited to 'static/app.js') diff --git a/static/app.js b/static/app.js index 678b59b..8f39fb8 100644 --- a/static/app.js +++ b/static/app.js @@ -9,8 +9,11 @@ const state = { ollamaStatus: 'unknown', currentGroceryId: null, editingIngredientId: null, + chatHistory: [], }; +let pendingSwap = null; + // ── API Utilities ────────────────────────────────────────────────────────── async function api(method, path, body = null) { const opts = { method, headers: { 'Content-Type': 'application/json' } }; @@ -103,6 +106,7 @@ function switchTab(tabName) { else if (tabName === 'meals') loadMeals(); else if (tabName === 'menu') loadMenu(); else if (tabName === 'grocery') loadGrocery(); + else if (tabName === 'chat') loadChat(); } // ── Pantry Tab ───────────────────────────────────────────────────────────── @@ -380,92 +384,139 @@ async function loadMenu() { }); if (!res) { - document.getElementById('menu-grid').innerHTML = ''; + document.getElementById('recipe-list').innerHTML = ''; document.getElementById('menu-empty').classList.remove('hidden'); document.getElementById('menu-notes').classList.add('hidden'); + document.getElementById('btn-clear-menu').classList.add('hidden'); return; } - state.currentMenu = res; + let planIds = []; + try { + const parsed = typeof res.plan === 'string' ? JSON.parse(res.plan) : res.plan; + planIds = Array.isArray(parsed) ? parsed : []; + } catch { planIds = []; } + + const allRecipes = await api('GET', '/recipes'); + const recipeMap = Object.fromEntries(allRecipes.map(r => [r.id, r])); + const planRecipes = planIds.map(id => recipeMap[id]).filter(Boolean); + + state.currentMenu = { ...res, recipes: planRecipes }; document.getElementById('menu-empty').classList.add('hidden'); + document.getElementById('btn-clear-menu').classList.remove('hidden'); if (res.notes) { document.getElementById('menu-notes').textContent = res.notes; document.getElementById('menu-notes').classList.remove('hidden'); + } else { + document.getElementById('menu-notes').classList.add('hidden'); } - renderMenuGrid(); + renderRecipeList(planRecipes); } 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']; +function renderRecipeList(recipes) { + const list = document.getElementById('recipe-list'); + if (!recipes || recipes.length === 0) { + list.innerHTML = ''; + return; + } - let plan = state.currentMenu.plan; - if (typeof plan === 'string') { - try { - plan = JSON.parse(plan); - } catch { - plan = {}; + list.innerHTML = recipes.map(recipe => { + let ingredients = recipe.ingredients || []; + if (typeof ingredients === 'string') { + try { ingredients = JSON.parse(ingredients); } catch { ingredients = []; } } - } - let weekPlan = state.currentMenu.week_plan; - if (typeof weekPlan === 'string') { - try { - weekPlan = JSON.parse(weekPlan); - } catch { - weekPlan = {}; + const ingredientText = Array.isArray(ingredients) + ? ingredients.map(i => { + if (typeof i === 'string') return i; + return [i.quantity, i.unit, i.name].filter(Boolean).join(' '); + }).join(', ') + : ''; + + const mealType = recipe.meal_type || 'dinner'; + const typeDisplay = mealType.charAt(0).toUpperCase() + mealType.slice(1); + const time = recipe.time_minutes || recipe.estimated_time_minutes; + const serves = recipe.serves || recipe.servings; + const meta = [time ? `${time} min` : '', serves ? `serves ${serves}` : ''].filter(Boolean).join(' · '); + + return ` +
+
+
${recipe.name}
+ ${typeDisplay} +
+ ${meta ? `
${meta}
` : ''} + ${ingredientText ? `
Ingredients: ${ingredientText}
` : ''} + ${recipe.instructions ? `
${recipe.instructions}
` : ''} +
+ + + +
+
+ `; + }).join(''); +} + +async function removeRecipe(recipeId, btn) { + btn.disabled = true; + try { + await api('DELETE', `/menus/current/recipes/${recipeId}`, null); + state.currentMenu.recipes = state.currentMenu.recipes.filter(r => r.id !== recipeId); + renderRecipeList(state.currentMenu.recipes); + if (state.currentMenu.recipes.length === 0) { + document.getElementById('menu-empty').classList.remove('hidden'); + document.getElementById('btn-clear-menu').classList.add('hidden'); } + } catch (err) { + showToast(err.message, 'error'); + btn.disabled = false; } +} - // Header row - let html = ''; - days.forEach(day => { - html += ``; - }); +function swapRecipe(recipeId, mealType, btn) { + pendingSwap = { recipeId, mealType, btn }; + const recipe = state.currentMenu.recipes.find(r => r.id === recipeId); + document.getElementById('swap-modal-subtitle').textContent = recipe ? `Replacing: ${recipe.name}` : ''; + document.getElementById('swap-notes').value = ''; + document.getElementById('swap-modal-overlay').classList.remove('hidden'); + document.getElementById('swap-notes').focus(); +} - // 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; - } +function cancelSwap() { + document.getElementById('swap-modal-overlay').classList.add('hidden'); + pendingSwap = null; +} - const typeDisplay = mealType.charAt(0).toUpperCase() + mealType.slice(1); - const warningClass = repeatWarning ? 'repeat-warning' : ''; +async function confirmSwap() { + if (!pendingSwap) return; + const { recipeId, btn } = pendingSwap; + const userNotes = document.getElementById('swap-notes').value.trim(); - html += ` - - `; - }); - }); + document.getElementById('swap-modal-overlay').classList.add('hidden'); + btn.disabled = true; + btn.textContent = '...'; + pendingSwap = null; - grid.innerHTML = html; + try { + const res = await api('POST', `/menus/current/recipes/${recipeId}/swap`, + userNotes ? { user_notes: userNotes } : {} + ); + const newRecipe = res.recipe; + state.currentMenu.recipes = state.currentMenu.recipes.map(r => + r.id === recipeId ? newRecipe : r + ); + renderRecipeList(state.currentMenu.recipes); + } catch (err) { + showToast(err.message, 'error'); + btn.disabled = false; + btn.textContent = 'Swap'; + } } async function generateMenu() { @@ -476,16 +527,18 @@ async function generateMenu() { spinner.classList.remove('hidden'); try { - const res = await api('POST', '/menus/generate', {}); - state.currentMenu = res; + const userNotes = document.getElementById('menu-user-notes').value.trim(); + const res = await api('POST', '/menus/generate', userNotes ? { user_notes: userNotes } : {}); + state.currentMenu = { ...res.menu_plan, recipes: res.recipes }; document.getElementById('menu-empty').classList.add('hidden'); + document.getElementById('btn-clear-menu').classList.remove('hidden'); if (res.notes) { document.getElementById('menu-notes').textContent = res.notes; document.getElementById('menu-notes').classList.remove('hidden'); } - renderMenuGrid(); + renderRecipeList(res.recipes); showToast('Menu generated!'); } catch (err) { if (err.message.includes('503')) { @@ -499,6 +552,20 @@ async function generateMenu() { } } +async function clearMenu() { + try { + await api('DELETE', '/menus/current', null); + state.currentMenu = null; + document.getElementById('recipe-list').innerHTML = ''; + document.getElementById('menu-empty').classList.remove('hidden'); + document.getElementById('menu-notes').classList.add('hidden'); + document.getElementById('btn-clear-menu').classList.add('hidden'); + showToast('Menu cleared'); + } catch (err) { + showToast(err.message, 'error'); + } +} + async function makeThisMeal(mealName, mealType) { try { const now = new Date(); @@ -528,12 +595,21 @@ async function loadGrocery() { document.getElementById('grocery-total').classList.add('hidden'); document.getElementById('grocery-empty').classList.remove('hidden'); document.getElementById('btn-mark-purchased').classList.add('hidden'); + document.getElementById('btn-clear-grocery').classList.add('hidden'); return; } state.currentGrocery = res; state.currentGroceryId = res.id; document.getElementById('grocery-empty').classList.add('hidden'); + document.getElementById('btn-clear-grocery').classList.remove('hidden'); + + if (res.notes) { + document.getElementById('grocery-notes').textContent = res.notes; + document.getElementById('grocery-notes').classList.remove('hidden'); + } else { + document.getElementById('grocery-notes').classList.add('hidden'); + } if (!res.is_purchased) { document.getElementById('btn-mark-purchased').classList.remove('hidden'); @@ -582,7 +658,12 @@ function renderGroceryList() { const usedIn = item.used_in ? item.used_in.join(', ') : ''; return `
- +
${item.name}
@@ -610,6 +691,25 @@ function renderGroceryList() { } } +async function checkGroceryItem(checkbox) { + if (!checkbox.checked) return; + checkbox.disabled = true; + + try { + await api('POST', '/pantry', { + name: checkbox.dataset.name, + quantity: parseFloat(checkbox.dataset.quantity) || 1, + unit: checkbox.dataset.unit, + category: checkbox.dataset.category || null, + }); + checkbox.closest('.grocery-item').classList.add('grocery-item-checked'); + } catch (err) { + showToast('Failed to add to pantry: ' + err.message, 'error'); + checkbox.checked = false; + checkbox.disabled = false; + } +} + async function generateGrocery() { const btn = document.getElementById('btn-generate-grocery'); const spinner = document.getElementById('grocery-spinner'); @@ -618,17 +718,23 @@ async function generateGrocery() { spinner.classList.remove('hidden'); try { - const res = await api('POST', '/grocery/generate', {}); + const userNotes = document.getElementById('grocery-user-notes').value.trim(); + const res = await api('POST', '/grocery/generate', userNotes ? { user_notes: userNotes } : {}); state.currentGrocery = res; - state.currentGroceryId = res.id; + state.currentGroceryId = res.grocery_list.id; document.getElementById('grocery-empty').classList.add('hidden'); document.getElementById('btn-mark-purchased').classList.remove('hidden'); + document.getElementById('btn-clear-grocery').classList.remove('hidden'); + + if (res.shopping_notes) { + document.getElementById('grocery-notes').textContent = res.shopping_notes; + document.getElementById('grocery-notes').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')) { + if (err.message.includes('503')) { showToast('Ollama is offline or not responding', 'error'); } else { showToast(err.message, 'error'); @@ -639,6 +745,23 @@ async function generateGrocery() { } } +async function clearGrocery() { + try { + await api('DELETE', '/grocery/current', null); + state.currentGrocery = null; + state.currentGroceryId = null; + document.getElementById('grocery-sections').innerHTML = ''; + document.getElementById('grocery-total').classList.add('hidden'); + document.getElementById('grocery-empty').classList.remove('hidden'); + document.getElementById('grocery-notes').classList.add('hidden'); + document.getElementById('btn-mark-purchased').classList.add('hidden'); + document.getElementById('btn-clear-grocery').classList.add('hidden'); + showToast('Grocery list cleared'); + } catch (err) { + showToast(err.message, 'error'); + } +} + async function markPurchased() { try { await api('PUT', `/grocery/${state.currentGroceryId}/purchased`, {}); @@ -650,6 +773,71 @@ async function markPurchased() { } } +// ── Chat Tab ─────────────────────────────────────────────────────────────── +function loadChat() { + renderChat(); +} + +function renderChat() { + const messagesDiv = document.getElementById('chat-messages'); + if (state.chatHistory.length === 0) { + messagesDiv.innerHTML = '
Hi! I\'m Commis, your personal chef assistant. Ask me anything about your pantry, recipes, or meal planning.
'; + return; + } + + messagesDiv.innerHTML = state.chatHistory.map(msg => ` +
+
${msg.content.replace(/\n/g, '
')}
+
+ `).join(''); + + messagesDiv.scrollTop = messagesDiv.scrollHeight; +} + +async function sendChatMessage() { + const input = document.getElementById('chat-input'); + const btn = document.getElementById('btn-send-chat'); + const message = input.value.trim(); + if (!message) return; + + input.value = ''; + input.disabled = true; + btn.disabled = true; + + state.chatHistory.push({ role: 'user', content: message }); + renderChat(); + + // Show typing indicator + const messagesDiv = document.getElementById('chat-messages'); + const typingEl = document.createElement('div'); + typingEl.className = 'chat-message chat-message-assistant'; + typingEl.id = 'chat-typing'; + typingEl.innerHTML = '
...
'; + messagesDiv.appendChild(typingEl); + messagesDiv.scrollTop = messagesDiv.scrollHeight; + + try { + const res = await api('POST', '/ai/chat', { + message, + history: state.chatHistory.slice(-20).slice(0, -1), + }); + + document.getElementById('chat-typing')?.remove(); + state.chatHistory.push({ role: 'assistant', content: res.response }); + renderChat(); + } catch (err) { + document.getElementById('chat-typing')?.remove(); + state.chatHistory.pop(); + showToast(err.message, 'error'); + input.value = message; + renderChat(); + } finally { + input.disabled = false; + btn.disabled = false; + input.focus(); + } +} + // ── Initialization ───────────────────────────────────────────────────────── async function init() { // Check Ollama status @@ -745,11 +933,22 @@ async function init() { // Set up menu form document.getElementById('btn-generate-menu').addEventListener('click', generateMenu); + document.getElementById('btn-clear-menu').addEventListener('click', clearMenu); // Set up grocery form document.getElementById('btn-generate-grocery').addEventListener('click', generateGrocery); + document.getElementById('btn-clear-grocery').addEventListener('click', clearGrocery); document.getElementById('btn-mark-purchased').addEventListener('click', markPurchased); + // Set up chat + document.getElementById('btn-send-chat').addEventListener('click', sendChatMessage); + document.getElementById('chat-input').addEventListener('keydown', e => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendChatMessage(); + } + }); + // Load initial tab await loadPantry(); } -- cgit v1.3-2-g0d8e