summaryrefslogtreecommitdiff
path: root/static/app.js
diff options
context:
space:
mode:
Diffstat (limited to 'static/app.js')
-rw-r--r--static/app.js335
1 files changed, 267 insertions, 68 deletions
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'];
-
- let plan = state.currentMenu.plan;
- if (typeof plan === 'string') {
- try {
- plan = JSON.parse(plan);
- } catch {
- plan = {};
- }
+function renderRecipeList(recipes) {
+ const list = document.getElementById('recipe-list');
+ if (!recipes || recipes.length === 0) {
+ list.innerHTML = '';
+ return;
}
- let weekPlan = state.currentMenu.week_plan;
- if (typeof weekPlan === 'string') {
- try {
- weekPlan = JSON.parse(weekPlan);
- } catch {
- weekPlan = {};
+ list.innerHTML = recipes.map(recipe => {
+ let ingredients = recipe.ingredients || [];
+ if (typeof ingredients === 'string') {
+ try { ingredients = JSON.parse(ingredients); } catch { ingredients = []; }
}
- }
- // Header row
- let html = '<div class="menu-grid-header"></div>';
- days.forEach(day => {
- html += `<div class="menu-grid-header">${day}</div>`;
- });
+ 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(', ')
+ : '';
- // Meal type rows
- mealTypes.forEach(mealType => {
- days.forEach(day => {
- const dayKey = day.toLowerCase();
- const dayPlan = plan[dayKey] || {};
- const recipeId = dayPlan[mealType];
+ 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(' · ');
- let recipeName = '—';
- let recipeTime = '';
- let repeatWarning = false;
+ return `
+ <div class="recipe-card">
+ <div class="recipe-card-header">
+ <div class="recipe-card-title">${recipe.name}</div>
+ <span class="recipe-type-badge ${mealType}">${typeDisplay}</span>
+ </div>
+ ${meta ? `<div class="recipe-card-meta">${meta}</div>` : ''}
+ ${ingredientText ? `<div class="recipe-card-ingredients"><strong>Ingredients:</strong> ${ingredientText}</div>` : ''}
+ ${recipe.instructions ? `<div class="recipe-card-instructions">${recipe.instructions}</div>` : ''}
+ <div class="recipe-card-actions">
+ <button class="btn btn-sm btn-primary" onclick="makeThisMeal('${recipe.name.replace(/'/g, "\\'")}', '${mealType}')">Make This</button>
+ <button class="btn btn-sm btn-secondary" onclick="swapRecipe(${recipe.id}, '${mealType}', this)">Swap</button>
+ <button class="btn btn-sm btn-danger" onclick="removeRecipe(${recipe.id}, this)">Remove</button>
+ </div>
+ </div>
+ `;
+ }).join('');
+}
- 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;
- }
+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;
+ }
+}
- const typeDisplay = mealType.charAt(0).toUpperCase() + mealType.slice(1);
- const warningClass = repeatWarning ? 'repeat-warning' : '';
+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();
+}
- 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>
- `;
- });
- });
+function cancelSwap() {
+ document.getElementById('swap-modal-overlay').classList.add('hidden');
+ pendingSwap = null;
+}
- grid.innerHTML = html;
+async function confirmSwap() {
+ if (!pendingSwap) return;
+ const { recipeId, btn } = pendingSwap;
+ const userNotes = document.getElementById('swap-notes').value.trim();
+
+ document.getElementById('swap-modal-overlay').classList.add('hidden');
+ btn.disabled = true;
+ btn.textContent = '...';
+ pendingSwap = null;
+
+ 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 `
<div class="grocery-item">
- <input type="checkbox">
+ <input type="checkbox"
+ data-name="${item.name.replace(/"/g, '&quot;')}"
+ data-quantity="${item.quantity || 0}"
+ data-unit="${(item.unit || '').replace(/"/g, '&quot;')}"
+ data-category="${(item.store_section || '').replace(/"/g, '&quot;')}"
+ onchange="checkGroceryItem(this)">
<div class="grocery-item-details">
<div class="grocery-item-name">${item.name}</div>
<div class="grocery-item-meta">
@@ -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 = '<div class="chat-welcome">Hi! I\'m Commis, your personal chef assistant. Ask me anything about your pantry, recipes, or meal planning.</div>';
+ return;
+ }
+
+ messagesDiv.innerHTML = state.chatHistory.map(msg => `
+ <div class="chat-message chat-message-${msg.role}">
+ <div class="chat-bubble">${msg.content.replace(/\n/g, '<br>')}</div>
+ </div>
+ `).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 = '<div class="chat-bubble chat-typing-indicator">...</div>';
+ 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();
}