From 879768f72bf6c0406708d164582e68d05f94a674 Mon Sep 17 00:00:00 2001
From: Peter Woolery
Date: Sun, 17 May 2026 13:12:18 -0700
Subject: [PATCH] fix: shopping list ingredient UUID parsing for prices/aisles;
feat: interactive checkboxes with localStorage persistence
---
README.md | 2 +-
backend/app/api/shopping_list.py | 28 +++--
frontend/src/pages/ShoppingList.tsx | 155 +++++++++++++++++++++-------
3 files changed, 133 insertions(+), 52 deletions(-)
diff --git a/README.md b/README.md
index c09996c..8f50dc0 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@ This project was born out of frustration with meal kit services (Blue Apron →
- **Grocery Integration**: Scrapes Lucky California weekly ads and sales
- **Family Approval Workflow**: Email proposals with approve/deny; one denial swaps the meal
-- **Shopping List Generation**: Weekly list grouped by store aisles, highlighting sales
+- **Shopping List Generation**: Weekly list grouped by store aisles, highlighting sales, with interactive checkboxes to track purchased items
- **Pantry Integration**: Specify home items to incorporate into suggestions
- **Web UI**: Modern interface for the whole family
- **Learning**: Feedback-based meal recommendations
diff --git a/backend/app/api/shopping_list.py b/backend/app/api/shopping_list.py
index d1dc56d..2de3919 100644
--- a/backend/app/api/shopping_list.py
+++ b/backend/app/api/shopping_list.py
@@ -10,10 +10,20 @@ from app.schemas import ShoppingListResponse, ShoppingListItem
from datetime import date
from typing import List
from collections import defaultdict
+from uuid import UUID
router = APIRouter()
+def _parse_uuid(value):
+ if not value:
+ return None
+ try:
+ return UUID(value) if isinstance(value, str) else value
+ except ValueError:
+ return None
+
+
@router.get("", response_model=ShoppingListResponse)
def get_shopping_list(db: Session = Depends(get_db)):
profile = db.query(FamilyProfile).first()
@@ -51,8 +61,9 @@ def get_shopping_list(db: Session = Depends(get_db)):
recipe = db.query(Recipe).filter(Recipe.id == item.recipe_id).first()
if recipe and recipe.ingredients:
for ing in recipe.ingredients:
- if ing.get('ingredient_id'):
- all_ingredient_ids.add(ing['ingredient_id'])
+ ing_id = _parse_uuid(ing.get('ingredient_id'))
+ if ing_id:
+ all_ingredient_ids.add(ing_id)
if all_ingredient_ids:
ingredients = db.query(Ingredient).filter(
@@ -71,19 +82,14 @@ def get_shopping_list(db: Session = Depends(get_db)):
aggregated = defaultdict(lambda: {"quantity": 0.0, "unit": None, "name": ""})
- # Build ingredient name lookup from IDs
- ingredient_names = {}
- if all_ingredient_ids:
- ingredient_names = {str(i.id): i.name for i in ingredients}
-
for plan_item in current_plan.items:
recipe = db.query(Recipe).filter(Recipe.id == plan_item.recipe_id).first()
if recipe and recipe.ingredients:
for ing in recipe.ingredients:
- ing_id = ing.get('ingredient_id')
+ ing_id = _parse_uuid(ing.get('ingredient_id'))
name = ing.get('name')
- if not name and ing_id:
- name = ingredient_names.get(str(ing_id))
+ if not name and ing_id and ing_id in ingredient_map:
+ name = ingredient_map[ing_id].name
if not name:
name = 'Unknown'
quantity = ing.get('quantity', 1.0) or 1.0
@@ -104,7 +110,7 @@ def get_shopping_list(db: Session = Depends(get_db)):
in_pantry = ingredient_id and ingredient_id in pantry_items
ing_obj = ingredient_map.get(ingredient_id) if ingredient_id else None
- price = ing_obj.typical_price if ing_obj else None
+ price = float(ing_obj.typical_price) if ing_obj and ing_obj.typical_price is not None else None
sale_price = None
is_on_sale = False
diff --git a/frontend/src/pages/ShoppingList.tsx b/frontend/src/pages/ShoppingList.tsx
index 72039af..d2d3868 100644
--- a/frontend/src/pages/ShoppingList.tsx
+++ b/frontend/src/pages/ShoppingList.tsx
@@ -1,5 +1,6 @@
+import { useState, useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
-import { Printer, ShoppingCart, Package, Tag, Receipt } from 'lucide-react'
+import { Printer, ShoppingCart, Package, Tag, Receipt, RotateCcw } from 'lucide-react'
import { mealPlannerApi } from '../api'
import type { ShoppingList } from '../types'
import { Button } from '../components/ui/Button'
@@ -40,12 +41,61 @@ function SkeletonCard() {
)
}
+function storageKey(week: string) {
+ return `shopping-list-checked-${week}`
+}
+
export default function ShoppingListPage() {
const { data: shoppingList, isLoading } = useQuery({
queryKey: ['shoppingList'],
queryFn: () => mealPlannerApi.shoppingList.get().then(r => r.data),
})
+ const week = shoppingList?.week_start_date || ''
+ const [checked, setChecked] = useState>(new Set())
+
+ useEffect(() => {
+ if (!week) return
+ try {
+ const raw = localStorage.getItem(storageKey(week))
+ if (raw) {
+ const parsed = JSON.parse(raw) as string[]
+ setChecked(new Set(parsed))
+ } else {
+ setChecked(new Set())
+ }
+ } catch {
+ setChecked(new Set())
+ }
+ }, [week])
+
+ useEffect(() => {
+ if (!week) return
+ localStorage.setItem(storageKey(week), JSON.stringify(Array.from(checked)))
+ }, [checked, week])
+
+ const toggle = (id: string) => {
+ setChecked(prev => {
+ const next = new Set(prev)
+ if (next.has(id)) next.delete(id)
+ else next.add(id)
+ return next
+ })
+ }
+
+ const clearAll = () => setChecked(new Set())
+
+ const progress =
+ shoppingList && shoppingList.items.length > 0
+ ? Math.round(
+ (Array.from(checked).filter(id =>
+ shoppingList.items.some(i => i.ingredient_id === id)
+ ).length /
+ shoppingList.items.length) *
+ 100
+ )
+ : 0
+
if (isLoading) {
return
}
@@ -85,9 +135,24 @@ export default function ShoppingListPage() {
- } onClick={() => window.print()}>
- Print List
-
+
+ {progress > 0 && (
+ {progress}% complete
+ )}
+ {checked.size > 0 && (
+ }
+ onClick={clearAll}
+ >
+ Reset
+
+ )}
+ } onClick={() => window.print()}>
+ Print List
+
+
{/* Summary Stats */}
@@ -146,44 +211,54 @@ export default function ShoppingListPage() {
{items.length} items
- {items.map((item, idx) => (
- -
-
-
-
-
{item.name}
-
- {item.in_pantry &&
In Pantry}
- {item.is_on_sale &&
SALE}
+ {items.map((item, idx) => {
+ const isChecked = item.ingredient_id ? checked.has(item.ingredient_id) : false
+ return (
+
-
+
+
item.ingredient_id && toggle(item.ingredient_id)}
+ />
+
+
+ {item.name}
+
+
+ {item.in_pantry && In Pantry}
+ {item.is_on_sale && SALE}
+
-
-
- {item.quantity && (
-
- {item.quantity} {item.unit || ''}
-
- )}
- {item.sale_price ? (
-
- ${item.sale_price.toFixed(2)}
- {item.estimated_price && item.estimated_price > item.sale_price && (
-
- ${item.estimated_price.toFixed(2)}
-
- )}
-
- ) : item.estimated_price ? (
-
- ${item.estimated_price.toFixed(2)}
-
- ) : null}
-
-
- ))}
+
+ {item.quantity && (
+
+ {item.quantity} {item.unit || ''}
+
+ )}
+ {item.sale_price ? (
+
+ ${item.sale_price.toFixed(2)}
+ {item.estimated_price && item.estimated_price > item.sale_price && (
+
+ ${item.estimated_price.toFixed(2)}
+
+ )}
+
+ ) : item.estimated_price ? (
+
+ ${item.estimated_price.toFixed(2)}
+
+ ) : null}
+
+
+ )
+ })}
))}