diff options
Diffstat (limited to 'static')
| -rw-r--r-- | static/app.js | 757 | ||||
| -rw-r--r-- | static/index.html | 127 | ||||
| -rw-r--r-- | static/style.css | 823 |
3 files changed, 1707 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); diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..a9ae018 --- /dev/null +++ b/static/index.html @@ -0,0 +1,127 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Commis</title> + <link rel="stylesheet" href="style.css"> +</head> +<body> + <header> + <div class="header-title">🔪 Commis</div> + <div id="ollama-status" class="status-badge">Checking...</div> + </header> + + <nav class="tab-nav"> + <button class="tab-btn active" data-tab="pantry">Pantry</button> + <button class="tab-btn" data-tab="meals">Meals</button> + <button class="tab-btn" data-tab="menu">This Week's Menu</button> + <button class="tab-btn" data-tab="grocery">Grocery List</button> + </nav> + + <main> + <!-- Pantry Tab --> + <div id="tab-pantry" class="tab-content active"> + <div class="tab-header"> + <h2>Pantry</h2> + <button class="btn btn-primary" id="btn-add-ingredient">+ Add Ingredient</button> + </div> + <!-- Add form (hidden by default) --> + <div id="add-ingredient-form" class="form-card hidden"> + <h3>Add Ingredient</h3> + <div class="form-row"> + <div class="form-group"><label>Name</label><input type="text" id="ing-name" placeholder="e.g. Chicken Breast"></div> + <div class="form-group"><label>Quantity</label><input type="number" id="ing-qty" step="0.1" placeholder="500"></div> + <div class="form-group"><label>Unit</label><input type="text" id="ing-unit" placeholder="grams"></div> + <div class="form-group"><label>Category</label> + <select id="ing-category"> + <option value="">None</option> + <option value="produce">Produce</option> + <option value="protein">Protein</option> + <option value="dairy">Dairy</option> + <option value="pantry">Pantry</option> + <option value="frozen">Frozen</option> + <option value="bakery">Bakery</option> + </select> + </div> + <div class="form-group"><label>Expiry Date</label><input type="date" id="ing-expiry"></div> + </div> + <div class="form-actions"> + <button class="btn btn-primary" id="btn-save-ingredient">Save</button> + <button class="btn" id="btn-cancel-ingredient">Cancel</button> + </div> + </div> + <div id="expiry-warning-banner" class="hidden"></div> + <table id="pantry-table"> + <thead><tr><th>Name</th><th>Quantity</th><th>Unit</th><th>Category</th><th>Expires</th><th>Actions</th></tr></thead> + <tbody id="pantry-tbody"></tbody> + </table> + <div id="pantry-empty" class="empty-state hidden">No ingredients yet. Add some to get started.</div> + </div> + + <!-- Meals Tab --> + <div id="tab-meals" class="tab-content hidden"> + <div class="tab-header"> + <h2>Meal Log</h2> + <button class="btn btn-primary" id="btn-log-meal">+ Log Meal</button> + </div> + <div id="log-meal-form" class="form-card hidden"> + <h3>Log a Meal</h3> + <div class="form-row"> + <div class="form-group"><label>Meal Name</label><input type="text" id="meal-name" placeholder="e.g. Chicken Stir Fry"></div> + <div class="form-group"><label>Type</label> + <select id="meal-type"> + <option value="breakfast">Breakfast</option> + <option value="lunch">Lunch</option> + <option value="dinner" selected>Dinner</option> + <option value="snack">Snack</option> + </select> + </div> + <div class="form-group"><label>Date & Time</label><input type="datetime-local" id="meal-datetime"></div> + <div class="form-group"><label>Servings</label><input type="number" id="meal-servings" value="1" min="1"></div> + <div class="form-group full-width"><label>Notes</label><input type="text" id="meal-notes" placeholder="Optional notes"></div> + </div> + <div class="form-actions"> + <button class="btn btn-primary" id="btn-save-meal">Save</button> + <button class="btn" id="btn-cancel-meal">Cancel</button> + </div> + </div> + <div id="meals-stats" class="stats-section"></div> + <div id="meals-timeline"></div> + <div id="meals-empty" class="empty-state hidden">No meals logged yet.</div> + </div> + + <!-- Menu Tab --> + <div id="tab-menu" class="tab-content hidden"> + <div class="tab-header"> + <h2>This Week's Menu</h2> + <div class="header-actions"> + <button class="btn btn-primary" id="btn-generate-menu">✨ Generate Menu</button> + <div id="menu-spinner" class="spinner hidden"></div> + </div> + </div> + <div id="menu-notes" class="info-banner hidden"></div> + <div id="menu-grid" class="menu-grid"></div> + <div id="menu-empty" class="empty-state hidden">No menu generated yet. Click "Generate Menu" to create one.</div> + </div> + + <!-- Grocery Tab --> + <div id="tab-grocery" class="tab-content hidden"> + <div class="tab-header"> + <h2>Grocery List</h2> + <div class="header-actions"> + <button class="btn btn-primary" id="btn-generate-grocery">✨ Generate List</button> + <button class="btn btn-success hidden" id="btn-mark-purchased">✓ Mark All Purchased</button> + <div id="grocery-spinner" class="spinner hidden"></div> + </div> + </div> + <div id="grocery-total" class="hidden"></div> + <div id="grocery-sections"></div> + <div id="grocery-empty" class="empty-state hidden">No grocery list yet. Generate a menu first, then generate the grocery list.</div> + </div> + </main> + + <div id="toast-container"></div> + <script src="app.js"></script> +</body> +</html> diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..18908c7 --- /dev/null +++ b/static/style.css @@ -0,0 +1,823 @@ +:root { + --bg: #0f1117; + --surface: #1a1d27; + --surface2: #252836; + --border: #2e3148; + --accent: #6c63ff; + --accent-hover: #8b84ff; + --text: #e2e8f0; + --text-muted: #94a3b8; + --success: #22c55e; + --warning: #f59e0b; + --danger: #ef4444; + --info: #3b82f6; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background-color: var(--bg); + color: var(--text); + line-height: 1.6; +} + +/* ── Header ─────────────────────────────────────────────────────────────── */ +header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.5rem 2rem; + background-color: var(--surface); + border-bottom: 1px solid var(--border); + position: sticky; + top: 0; + z-index: 100; +} + +.header-title { + font-size: 1.5rem; + font-weight: 600; + letter-spacing: 0.5px; +} + +.status-badge { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + border-radius: 1rem; + background-color: var(--surface2); + border: 1px solid var(--border); + font-size: 0.875rem; + font-weight: 500; +} + +.status-ok { + border-color: var(--success); + color: var(--success); +} + +.status-error { + border-color: var(--danger); + color: var(--danger); +} + +/* ── Tab Navigation ───────────────────────────────────────────────────────── */ +.tab-nav { + display: flex; + gap: 0.5rem; + padding: 1rem 2rem; + background-color: var(--surface); + border-bottom: 1px solid var(--border); + overflow-x: auto; +} + +.tab-btn { + padding: 0.75rem 1.5rem; + background-color: transparent; + border: 1px solid var(--border); + border-radius: 2rem; + color: var(--text-muted); + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; +} + +.tab-btn:hover { + border-color: var(--accent); + color: var(--text); +} + +.tab-btn.active { + background-color: var(--accent); + border-color: var(--accent); + color: var(--bg); +} + +/* ── Main Content ───────────────────────────────────────────────────────── */ +main { + max-width: 1400px; + margin: 0 auto; + padding: 2rem; +} + +.tab-content { + display: none; +} + +.tab-content.active { + display: block; + animation: fadeIn 0.2s ease; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.tab-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; +} + +.tab-header h2 { + font-size: 1.875rem; + font-weight: 600; +} + +.header-actions { + display: flex; + gap: 1rem; + align-items: center; +} + +/* ── Forms ──────────────────────────────────────────────────────────────── */ +.form-card { + background-color: var(--surface); + border: 1px solid var(--border); + border-radius: 0.5rem; + padding: 1.5rem; + margin-bottom: 2rem; +} + +.form-card h3 { + margin-bottom: 1.5rem; + font-size: 1.25rem; +} + +.form-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1.5rem; + margin-bottom: 1.5rem; +} + +.form-group { + display: flex; + flex-direction: column; +} + +.form-group.full-width { + grid-column: 1 / -1; +} + +.form-group label { + font-size: 0.875rem; + font-weight: 600; + margin-bottom: 0.5rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.form-group input, +.form-group select { + padding: 0.75rem; + background-color: var(--surface2); + border: 1px solid var(--border); + border-radius: 0.375rem; + color: var(--text); + font-size: 0.9rem; + transition: border-color 0.2s ease; +} + +.form-group input:focus, +.form-group select:focus { + outline: none; + border-color: var(--accent); + background-color: rgba(108, 99, 255, 0.05); +} + +.form-actions { + display: flex; + gap: 1rem; + justify-content: flex-end; +} + +/* ── Buttons ────────────────────────────────────────────────────────────── */ +.btn { + padding: 0.75rem 1.5rem; + background-color: var(--surface2); + border: 1px solid var(--border); + border-radius: 0.375rem; + color: var(--text); + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; +} + +.btn:hover { + background-color: var(--border); + border-color: var(--text-muted); +} + +.btn:active { + transform: scale(0.98); +} + +.btn-primary { + background-color: var(--accent); + border-color: var(--accent); + color: var(--bg); +} + +.btn-primary:hover { + background-color: var(--accent-hover); + border-color: var(--accent-hover); +} + +.btn-primary:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; +} + +.btn-danger { + background-color: var(--danger); + border-color: var(--danger); + color: white; +} + +.btn-danger:hover { + background-color: #dc2626; + border-color: #dc2626; +} + +.btn-success { + background-color: var(--success); + border-color: var(--success); + color: var(--bg); +} + +.btn-success:hover { + background-color: #16a34a; + border-color: #16a34a; +} + +.btn-sm { + padding: 0.5rem 1rem; + font-size: 0.8rem; +} + +/* ── Tables ────────────────────────────────────────────────────────────── */ +table { + width: 100%; + border-collapse: collapse; + background-color: var(--surface); + border: 1px solid var(--border); + border-radius: 0.5rem; + overflow: hidden; +} + +thead { + background-color: var(--surface2); + border-bottom: 1px solid var(--border); +} + +th { + padding: 1rem; + text-align: left; + font-weight: 600; + color: var(--text-muted); + font-size: 0.875rem; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +td { + padding: 1rem; + border-top: 1px solid var(--border); + font-size: 0.9rem; +} + +tbody tr:nth-child(odd) { + background-color: rgba(255, 255, 255, 0.01); +} + +tbody tr:hover { + background-color: rgba(108, 99, 255, 0.1); +} + +tbody tr.expiry-danger { + background-color: rgba(239, 68, 68, 0.1); + border-left: 3px solid var(--danger); +} + +tbody tr.expiry-warn { + background-color: rgba(245, 158, 11, 0.1); + border-left: 3px solid var(--warning); +} + +/* ── Cards ──────────────────────────────────────────────────────────────── */ +.card { + background-color: var(--surface); + border: 1px solid var(--border); + border-radius: 0.5rem; + padding: 1.5rem; + transition: all 0.2s ease; +} + +.card:hover { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(108, 99, 255, 0.1); +} + +.card-title { + font-size: 1.125rem; + font-weight: 600; + margin-bottom: 0.5rem; +} + +.card-subtitle { + font-size: 0.875rem; + color: var(--text-muted); + margin-bottom: 1rem; +} + +.card-body { + font-size: 0.9rem; +} + +/* ── Menu Grid ──────────────────────────────────────────────────────────── */ +.menu-grid { + display: grid; + grid-template-columns: repeat(8, 1fr); + gap: 0.5rem; + margin-bottom: 2rem; +} + +.menu-grid-cell { + background-color: var(--surface); + border: 1px solid var(--border); + border-radius: 0.5rem; + padding: 1rem; + min-height: 120px; + display: flex; + flex-direction: column; + justify-content: space-between; + transition: all 0.2s ease; +} + +.menu-grid-cell:hover { + border-color: var(--accent); +} + +.menu-grid-cell.repeat-warning { + border: 2px solid var(--warning); +} + +.menu-grid-header { + background-color: var(--surface2); + font-weight: 600; + text-align: center; + padding: 1rem; + border-radius: 0.5rem; + border: 1px solid var(--border); +} + +.menu-meal-type { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + color: var(--text-muted); + margin-bottom: 0.5rem; +} + +.menu-meal-name { + font-weight: 600; + margin-bottom: 0.5rem; + word-break: break-word; +} + +.menu-time { + display: inline-block; + font-size: 0.75rem; + background-color: var(--surface2); + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + margin-bottom: 0.75rem; + color: var(--text-muted); +} + +.menu-actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +@media (max-width: 768px) { + .menu-grid { + grid-template-columns: 1fr; + } + + .menu-grid-header { + display: none; + } + + .menu-grid-cell { + position: relative; + } + + .menu-grid-cell::before { + content: attr(data-day); + position: absolute; + top: 0.5rem; + right: 0.5rem; + font-size: 0.75rem; + color: var(--text-muted); + background-color: var(--surface2); + padding: 0.25rem 0.5rem; + border-radius: 0.25rem; + } +} + +/* ── Grocery List ───────────────────────────────────────────────────────── */ +.grocery-section { + margin-bottom: 2rem; +} + +.grocery-section-title { + font-weight: 600; + font-size: 1.125rem; + margin-bottom: 1rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--border); + text-transform: capitalize; +} + +.grocery-item { + display: flex; + align-items: flex-start; + gap: 1rem; + padding: 0.75rem; + background-color: rgba(255, 255, 255, 0.01); + border-radius: 0.375rem; + margin-bottom: 0.5rem; + font-size: 0.9rem; +} + +.grocery-item input[type="checkbox"] { + margin-top: 0.25rem; + cursor: pointer; + width: 18px; + height: 18px; + accent-color: var(--accent); +} + +.grocery-item-details { + flex: 1; +} + +.grocery-item-name { + font-weight: 500; + margin-bottom: 0.25rem; +} + +.grocery-item-meta { + font-size: 0.8rem; + color: var(--text-muted); +} + +.grocery-total { + background-color: var(--surface2); + border: 1px solid var(--border); + border-radius: 0.5rem; + padding: 1.5rem; + margin-bottom: 2rem; + text-align: center; +} + +.grocery-total-label { + font-size: 0.9rem; + color: var(--text-muted); + margin-bottom: 0.5rem; + text-transform: uppercase; + letter-spacing: 0.5px; + font-weight: 600; +} + +.grocery-total-amount { + font-size: 2rem; + font-weight: 700; + color: var(--accent); +} + +/* ── Stats Bar Chart ────────────────────────────────────────────────────── */ +.stats-section { + background-color: var(--surface); + border: 1px solid var(--border); + border-radius: 0.5rem; + padding: 1.5rem; + margin-bottom: 2rem; +} + +.stats-title { + font-size: 1.125rem; + font-weight: 600; + margin-bottom: 1.5rem; +} + +.stat-item { + display: grid; + grid-template-columns: 150px 1fr 80px; + gap: 1rem; + align-items: center; + margin-bottom: 1rem; +} + +.stat-label { + font-size: 0.9rem; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.stat-bar { + display: flex; + align-items: center; + height: 28px; + background-color: var(--surface2); + border-radius: 0.375rem; + overflow: hidden; +} + +.stat-bar-fill { + background: linear-gradient(90deg, var(--accent), var(--accent-hover)); + height: 100%; + display: flex; + align-items: center; + justify-content: flex-end; + padding-right: 0.5rem; + color: var(--bg); + font-size: 0.8rem; + font-weight: 600; + transition: width 0.3s ease; +} + +.stat-count { + font-size: 0.9rem; + font-weight: 600; + text-align: right; + color: var(--text-muted); +} + +/* ── Meals Timeline ────────────────────────────────────────────────────── */ +.meals-timeline { + display: flex; + flex-direction: column; + gap: 2rem; +} + +.meal-date-group { + border-left: 3px solid var(--accent); + padding-left: 1.5rem; +} + +.meal-date-label { + font-weight: 600; + font-size: 1rem; + margin-bottom: 1rem; + color: var(--accent); +} + +.meal-entry { + background-color: var(--surface); + border: 1px solid var(--border); + border-radius: 0.5rem; + padding: 1rem; + margin-bottom: 0.75rem; + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; +} + +.meal-type-badge { + display: inline-block; + padding: 0.25rem 0.75rem; + border-radius: 0.25rem; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + margin-bottom: 0.5rem; +} + +.meal-type-breakfast { + background-color: rgba(245, 158, 11, 0.2); + color: var(--warning); +} + +.meal-type-lunch { + background-color: rgba(108, 99, 255, 0.2); + color: var(--accent); +} + +.meal-type-dinner { + background-color: rgba(34, 197, 94, 0.2); + color: var(--success); +} + +.meal-type-snack { + background-color: rgba(59, 130, 246, 0.2); + color: var(--info); +} + +.meal-info { + flex: 1; +} + +.meal-name { + font-weight: 600; + margin-bottom: 0.25rem; +} + +.meal-time { + font-size: 0.8rem; + color: var(--text-muted); +} + +.meal-notes { + font-size: 0.85rem; + color: var(--text-muted); + margin-top: 0.5rem; + font-style: italic; +} + +/* ── Spinners ───────────────────────────────────────────────────────────── */ +.spinner { + display: inline-block; + width: 20px; + height: 20px; + border: 2px solid var(--surface2); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* ── Toasts ────────────────────────────────────────────────────────────── */ +#toast-container { + position: fixed; + bottom: 2rem; + right: 2rem; + z-index: 1000; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.toast { + background-color: var(--surface); + border: 1px solid var(--border); + border-radius: 0.5rem; + padding: 1rem 1.5rem; + min-width: 280px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + animation: slideIn 0.3s ease; + display: flex; + gap: 0.75rem; + align-items: flex-start; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateX(400px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes slideOut { + to { + opacity: 0; + transform: translateX(400px); + } +} + +.toast.exiting { + animation: slideOut 0.3s ease forwards; +} + +.toast-icon { + flex-shrink: 0; + font-size: 1.25rem; + line-height: 1; +} + +.toast-message { + flex: 1; + font-size: 0.9rem; +} + +.toast-success { + border-color: var(--success); +} + +.toast-success .toast-icon { + color: var(--success); +} + +.toast-error { + border-color: var(--danger); +} + +.toast-error .toast-icon { + color: var(--danger); +} + +/* ── Utility Classes ────────────────────────────────────────────────────── */ +.hidden { + display: none !important; +} + +.empty-state { + text-align: center; + padding: 3rem 2rem; + color: var(--text-muted); + font-size: 1rem; +} + +.info-banner { + background-color: rgba(59, 130, 246, 0.1); + border: 1px solid rgba(59, 130, 246, 0.3); + border-radius: 0.5rem; + padding: 1rem; + margin-bottom: 1.5rem; + color: var(--info); + font-size: 0.9rem; +} + +.warning-banner { + background-color: rgba(245, 158, 11, 0.1); + border: 1px solid rgba(245, 158, 11, 0.3); + border-radius: 0.5rem; + padding: 1rem; + margin-bottom: 1.5rem; + color: var(--warning); + font-size: 0.9rem; +} + +/* ── Responsive ────────────────────────────────────────────────────────── */ +@media (max-width: 768px) { + main { + padding: 1rem; + } + + header { + padding: 1rem; + flex-direction: column; + gap: 1rem; + } + + .tab-nav { + padding: 0.5rem 1rem; + gap: 0.25rem; + } + + .tab-btn { + padding: 0.5rem 1rem; + font-size: 0.8rem; + } + + .tab-header { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + .form-row { + grid-template-columns: 1fr; + } + + .stat-item { + grid-template-columns: 120px 1fr 60px; + } + + #toast-container { + bottom: 1rem; + right: 1rem; + left: 1rem; + } + + .toast { + min-width: unset; + } +} |
