// ── 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}
`;
}).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 `
`;
}).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);