summaryrefslogtreecommitdiff
path: root/static/app.js
diff options
context:
space:
mode:
authorTyler Hoang <tyler@tylerhoang.xyz>2026-05-09 01:19:08 -0700
committerTyler Hoang <tyler@tylerhoang.xyz>2026-05-09 01:19:08 -0700
commit75ce40635736260ce5a19b7a33856305ee516ccc (patch)
tree9db3931eae3dbd9308bab0e240e7c75950bcbd4c /static/app.js
parent3de7c5eed5ba262abf0d746211e33800db6d66df (diff)
Simplify pantry UX and persist grocery check state server-side
- Remove quantity, unit, expiry, and category fields from pantry form; auto-detect category via keyword map - Persist grocery item checked/unchecked state in DB via PATCH /api/grocery/{id}/check-item - Fix unchecking: previously bailed early and never removed the checked class - Fix NOT NULL constraint on ingredients.unit by defaulting to empty string Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'static/app.js')
-rw-r--r--static/app.js124
1 files changed, 50 insertions, 74 deletions
diff --git a/static/app.js b/static/app.js
index 8f39fb8..724e565 100644
--- a/static/app.js
+++ b/static/app.js
@@ -86,6 +86,17 @@ function daysUntilExpiry(expiryDateStr) {
return days;
}
+// ── Pantry Category Auto-Detection ──────────────────────────────────────
+function autoCategory(name) {
+ const n = name.toLowerCase();
+ if (/\b(apple|banana|berry|berries|broccoli|carrot|celery|corn|cucumber|garlic|grape|greens|kale|lemon|lettuce|lime|mango|mushroom|onion|orange|pepper|potato|spinach|tomato|zucchini|avocado|cabbage|cauliflower|beet|pea|herb|basil|cilantro|parsley|thyme|rosemary|ginger|scallion|leek|squash|radish|arugula|asparagus|artichoke|eggplant)\b/.test(n)) return 'produce';
+ if (/\b(milk|cheese|butter|cream|yogurt|egg|eggs|cheddar|mozzarella|parmesan|ricotta|sour cream|half.and.half|heavy cream|kefir)\b/.test(n)) return 'dairy';
+ if (/\b(chicken|beef|pork|turkey|fish|salmon|tuna|shrimp|lamb|bacon|sausage|ham|steak|ground|tofu|tempeh|lentil|bean|beans|chickpea)\b/.test(n)) return 'protein';
+ if (/\b(bread|bun|baguette|tortilla|roll|bagel|muffin|croissant|pita)\b/.test(n)) return 'bakery';
+ if (/\b(ice cream|frozen|edamame)\b/.test(n)) return 'frozen';
+ return 'pantry';
+}
+
// ── Tab Switching ──────────────────────────────────────────────────────────
function switchTab(tabName) {
state.activeTab = tabName;
@@ -114,7 +125,6 @@ async function loadPantry() {
try {
state.pantry = await api('GET', '/pantry');
renderPantryTable();
- updateExpiryWarning();
} catch (err) {
showToast(err.message, 'error');
}
@@ -136,22 +146,11 @@ function renderPantryTable() {
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}">
+ <tr 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>
@@ -159,30 +158,8 @@ function renderPantryTable() {
}).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');
@@ -190,20 +167,15 @@ async function addIngredient() {
}
try {
+ const category = autoCategory(name);
await api('POST', '/pantry', {
name,
- quantity: qty || 0,
- unit,
- category: category || null,
- expiry_date: expiry || null,
+ quantity: 1,
+ category,
});
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!');
@@ -218,10 +190,6 @@ function editIngredient(id) {
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';
}
@@ -656,9 +624,12 @@ function renderGroceryList() {
<div class="grocery-section-title">${titleCased}</div>
${grouped[section].map(item => {
const usedIn = item.used_in ? item.used_in.join(', ') : '';
+ const isChecked = item.checked ? 'checked' : '';
+ const checkedClass = item.checked ? 'grocery-item-checked' : '';
return `
- <div class="grocery-item">
+ <div class="grocery-item ${checkedClass}">
<input type="checkbox"
+ ${isChecked}
data-name="${item.name.replace(/"/g, '&quot;')}"
data-quantity="${item.quantity || 0}"
data-unit="${(item.unit || '').replace(/"/g, '&quot;')}"
@@ -692,22 +663,35 @@ function renderGroceryList() {
}
async function checkGroceryItem(checkbox) {
- if (!checkbox.checked) return;
- checkbox.disabled = true;
+ const checked = checkbox.checked;
+ const row = checkbox.closest('.grocery-item');
- 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;
+ if (checked) {
+ 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,
+ });
+ row.classList.add('grocery-item-checked');
+ } catch (err) {
+ showToast('Failed to add to pantry: ' + err.message, 'error');
+ checkbox.checked = false;
+ checkbox.disabled = false;
+ return;
+ } finally {
+ checkbox.disabled = false;
+ }
+ } else {
+ row.classList.remove('grocery-item-checked');
}
+
+ api('PATCH', `/grocery/${state.currentGroceryId}/check-item`, {
+ name: checkbox.dataset.name,
+ checked,
+ }).catch(err => console.error('Failed to update check state:', err));
}
async function generateGrocery() {
@@ -720,6 +704,7 @@ async function generateGrocery() {
try {
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.grocery_list.id;
document.getElementById('grocery-empty').classList.add('hidden');
@@ -748,6 +733,7 @@ async function generateGrocery() {
async function clearGrocery() {
try {
await api('DELETE', '/grocery/current', null);
+
state.currentGrocery = null;
state.currentGroceryId = null;
document.getElementById('grocery-sections').innerHTML = '';
@@ -866,10 +852,6 @@ async function init() {
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');
});
@@ -884,10 +866,6 @@ async function init() {
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');
@@ -895,12 +873,10 @@ async function init() {
}
try {
+ const category = autoCategory(name);
await api('PUT', `/pantry/${state.editingIngredientId}`, {
name,
- quantity: qty || 0,
- unit,
- category: category || null,
- expiry_date: expiry || null,
+ category,
});
document.getElementById('add-ingredient-form').classList.add('hidden');