fix: shopping list ingredient UUID parsing for prices/aisles; feat: interactive checkboxes with localStorage persistence

This commit is contained in:
2026-05-17 13:12:18 -07:00
parent 2f6ab006de
commit 879768f72b
3 changed files with 133 additions and 52 deletions
+1 -1
View File
@@ -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
+17 -11
View File
@@ -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
+84 -9
View File
@@ -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<ShoppingList>({
queryKey: ['shoppingList'],
queryFn: () => mealPlannerApi.shoppingList.get().then(r => r.data),
})
const week = shoppingList?.week_start_date || ''
const [checked, setChecked] = useState<Set<string>>(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 <ShoppingListSkeleton />
}
@@ -85,10 +135,25 @@ export default function ShoppingListPage() {
</p>
</div>
</div>
<div className="flex items-center gap-3">
{progress > 0 && (
<span className="text-sm text-surface-500">{progress}% complete</span>
)}
{checked.size > 0 && (
<Button
variant="ghost"
size="sm"
icon={<RotateCcw className="w-4 h-4" />}
onClick={clearAll}
>
Reset
</Button>
)}
<Button variant="secondary" icon={<Printer className="w-4 h-4" />} onClick={() => window.print()}>
Print List
</Button>
</div>
</div>
{/* Summary Stats */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
@@ -146,15 +211,24 @@ export default function ShoppingListPage() {
<Badge variant="neutral">{items.length} items</Badge>
</div>
<ul className="divide-y divide-surface-200">
{items.map((item, idx) => (
{items.map((item, idx) => {
const isChecked = item.ingredient_id ? checked.has(item.ingredient_id) : false
return (
<li
key={idx}
className="px-5 py-3.5 flex items-center justify-between hover:bg-surface-50 transition-colors"
className={`px-5 py-3.5 flex items-center justify-between hover:bg-surface-50 transition-colors ${isChecked ? 'opacity-60' : ''}`}
>
<div className="flex items-center gap-3">
<div className="w-5 h-5 rounded border-2 border-surface-300 flex-shrink-0" />
<input
type="checkbox"
className="w-5 h-5 rounded border-2 border-surface-300 text-primary-600 focus:ring-primary-500 cursor-pointer flex-shrink-0"
checked={isChecked}
onChange={() => item.ingredient_id && toggle(item.ingredient_id)}
/>
<div>
<span className="text-sm font-medium text-surface-900">{item.name}</span>
<span className={`text-sm font-medium text-surface-900 ${isChecked ? 'line-through text-surface-400' : ''}`}>
{item.name}
</span>
<div className="flex gap-1.5 mt-0.5">
{item.in_pantry && <Badge variant="success">In Pantry</Badge>}
{item.is_on_sale && <Badge variant="danger">SALE</Badge>}
@@ -163,13 +237,13 @@ export default function ShoppingListPage() {
</div>
<div className="text-right flex items-center gap-3">
{item.quantity && (
<span className="text-sm text-surface-500">
<span className={`text-sm text-surface-500 ${isChecked ? 'line-through' : ''}`}>
{item.quantity} {item.unit || ''}
</span>
)}
{item.sale_price ? (
<div className="flex flex-col items-end">
<span className="text-sm font-semibold text-danger-600">${item.sale_price.toFixed(2)}</span>
<span className={`text-sm font-semibold text-danger-600 ${isChecked ? 'line-through' : ''}`}>${item.sale_price.toFixed(2)}</span>
{item.estimated_price && item.estimated_price > item.sale_price && (
<span className="text-xs text-surface-400 line-through">
${item.estimated_price.toFixed(2)}
@@ -177,13 +251,14 @@ export default function ShoppingListPage() {
)}
</div>
) : item.estimated_price ? (
<span className="text-sm font-medium text-surface-700">
<span className={`text-sm font-medium text-surface-700 ${isChecked ? 'line-through' : ''}`}>
${item.estimated_price.toFixed(2)}
</span>
) : null}
</div>
</li>
))}
)
})}
</ul>
</Card>
))}