summaryrefslogtreecommitdiff
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
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>
-rw-r--r--routers/grocery.py37
-rw-r--r--schemas.py4
-rw-r--r--static/app.js124
-rw-r--r--static/index.html17
-rw-r--r--static/style.css9
5 files changed, 90 insertions, 101 deletions
diff --git a/routers/grocery.py b/routers/grocery.py
index 17215d2..cbc66bb 100644
--- a/routers/grocery.py
+++ b/routers/grocery.py
@@ -130,6 +130,43 @@ async def delete_current_grocery_list(db: Session = Depends(get_db)):
return {"status": "deleted"}
+@router.patch("/{id}/check-item")
+async def check_grocery_item(id: int, request: dict = Body(...), db: Session = Depends(get_db)):
+ """Mark a grocery item as checked/unchecked."""
+ grocery_list = db.query(GroceryList).filter(GroceryList.id == id).first()
+ if not grocery_list:
+ raise HTTPException(status_code=404, detail="Grocery list not found")
+
+ item_name = request.get("name")
+ checked = request.get("checked", False)
+
+ if not item_name:
+ raise HTTPException(status_code=400, detail="Item name is required")
+
+ # Parse items
+ try:
+ items = json.loads(grocery_list.items)
+ except json.JSONDecodeError:
+ items = []
+
+ # Find and update the item
+ item_found = False
+ for item in items:
+ if isinstance(item, dict) and item.get("name") == item_name:
+ item["checked"] = checked
+ item_found = True
+ break
+
+ if not item_found:
+ raise HTTPException(status_code=404, detail="Item not found in grocery list")
+
+ # Save back to database
+ grocery_list.items = json.dumps(items)
+ db.commit()
+
+ return {"status": "ok"}
+
+
@router.put("/{id}/purchased")
async def mark_purchased(id: int, db: Session = Depends(get_db)):
"""Mark a grocery list as purchased and update pantry."""
diff --git a/schemas.py b/schemas.py
index fe7bee2..b21c1f0 100644
--- a/schemas.py
+++ b/schemas.py
@@ -6,8 +6,8 @@ from typing import Optional, List, Dict
# Ingredient Schemas
class IngredientCreate(BaseModel):
name: str
- quantity: float
- unit: str
+ quantity: float = 1.0
+ unit: str = ""
category: Optional[str] = None
expiry_date: Optional[date] = None
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');
diff --git a/static/index.html b/static/index.html
index d0e0243..ae36a4f 100644
--- a/static/index.html
+++ b/static/index.html
@@ -33,29 +33,14 @@
<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>
+ <thead><tr><th>Name</th><th>Category</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>
diff --git a/static/style.css b/static/style.css
index 4cc033b..c530839 100644
--- a/static/style.css
+++ b/static/style.css
@@ -324,15 +324,6 @@ 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 {