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 - **Grocery Integration**: Scrapes Lucky California weekly ads and sales
- **Family Approval Workflow**: Email proposals with approve/deny; one denial swaps the meal - **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 - **Pantry Integration**: Specify home items to incorporate into suggestions
- **Web UI**: Modern interface for the whole family - **Web UI**: Modern interface for the whole family
- **Learning**: Feedback-based meal recommendations - **Learning**: Feedback-based meal recommendations
+17 -11
View File
@@ -10,10 +10,20 @@ from app.schemas import ShoppingListResponse, ShoppingListItem
from datetime import date from datetime import date
from typing import List from typing import List
from collections import defaultdict from collections import defaultdict
from uuid import UUID
router = APIRouter() 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) @router.get("", response_model=ShoppingListResponse)
def get_shopping_list(db: Session = Depends(get_db)): def get_shopping_list(db: Session = Depends(get_db)):
profile = db.query(FamilyProfile).first() 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() recipe = db.query(Recipe).filter(Recipe.id == item.recipe_id).first()
if recipe and recipe.ingredients: if recipe and recipe.ingredients:
for ing in recipe.ingredients: for ing in recipe.ingredients:
if ing.get('ingredient_id'): ing_id = _parse_uuid(ing.get('ingredient_id'))
all_ingredient_ids.add(ing['ingredient_id']) if ing_id:
all_ingredient_ids.add(ing_id)
if all_ingredient_ids: if all_ingredient_ids:
ingredients = db.query(Ingredient).filter( 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": ""}) 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: for plan_item in current_plan.items:
recipe = db.query(Recipe).filter(Recipe.id == plan_item.recipe_id).first() recipe = db.query(Recipe).filter(Recipe.id == plan_item.recipe_id).first()
if recipe and recipe.ingredients: if recipe and recipe.ingredients:
for ing in 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') name = ing.get('name')
if not name and ing_id: if not name and ing_id and ing_id in ingredient_map:
name = ingredient_names.get(str(ing_id)) name = ingredient_map[ing_id].name
if not name: if not name:
name = 'Unknown' name = 'Unknown'
quantity = ing.get('quantity', 1.0) or 1.0 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 in_pantry = ingredient_id and ingredient_id in pantry_items
ing_obj = ingredient_map.get(ingredient_id) if ingredient_id else None 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 sale_price = None
is_on_sale = False is_on_sale = False
+115 -40
View File
@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react'
import { useQuery } from '@tanstack/react-query' 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 { mealPlannerApi } from '../api'
import type { ShoppingList } from '../types' import type { ShoppingList } from '../types'
import { Button } from '../components/ui/Button' 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() { export default function ShoppingListPage() {
const { data: shoppingList, isLoading } = useQuery<ShoppingList>({ const { data: shoppingList, isLoading } = useQuery<ShoppingList>({
queryKey: ['shoppingList'], queryKey: ['shoppingList'],
queryFn: () => mealPlannerApi.shoppingList.get().then(r => r.data), 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) { if (isLoading) {
return <ShoppingListSkeleton /> return <ShoppingListSkeleton />
} }
@@ -85,9 +135,24 @@ export default function ShoppingListPage() {
</p> </p>
</div> </div>
</div> </div>
<Button variant="secondary" icon={<Printer className="w-4 h-4" />} onClick={() => window.print()}> <div className="flex items-center gap-3">
Print List {progress > 0 && (
</Button> <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> </div>
{/* Summary Stats */} {/* Summary Stats */}
@@ -146,44 +211,54 @@ export default function ShoppingListPage() {
<Badge variant="neutral">{items.length} items</Badge> <Badge variant="neutral">{items.length} items</Badge>
</div> </div>
<ul className="divide-y divide-surface-200"> <ul className="divide-y divide-surface-200">
{items.map((item, idx) => ( {items.map((item, idx) => {
<li const isChecked = item.ingredient_id ? checked.has(item.ingredient_id) : false
key={idx} return (
className="px-5 py-3.5 flex items-center justify-between hover:bg-surface-50 transition-colors" <li
> key={idx}
<div className="flex items-center gap-3"> className={`px-5 py-3.5 flex items-center justify-between hover:bg-surface-50 transition-colors ${isChecked ? 'opacity-60' : ''}`}
<div className="w-5 h-5 rounded border-2 border-surface-300 flex-shrink-0" /> >
<div> <div className="flex items-center gap-3">
<span className="text-sm font-medium text-surface-900">{item.name}</span> <input
<div className="flex gap-1.5 mt-0.5"> type="checkbox"
{item.in_pantry && <Badge variant="success">In Pantry</Badge>} className="w-5 h-5 rounded border-2 border-surface-300 text-primary-600 focus:ring-primary-500 cursor-pointer flex-shrink-0"
{item.is_on_sale && <Badge variant="danger">SALE</Badge>} checked={isChecked}
onChange={() => item.ingredient_id && toggle(item.ingredient_id)}
/>
<div>
<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>}
</div>
</div> </div>
</div> </div>
</div> <div className="text-right flex items-center gap-3">
<div className="text-right flex items-center gap-3"> {item.quantity && (
{item.quantity && ( <span className={`text-sm text-surface-500 ${isChecked ? 'line-through' : ''}`}>
<span className="text-sm text-surface-500"> {item.quantity} {item.unit || ''}
{item.quantity} {item.unit || ''} </span>
</span> )}
)} {item.sale_price ? (
{item.sale_price ? ( <div className="flex flex-col items-end">
<div className="flex flex-col items-end"> <span className={`text-sm font-semibold text-danger-600 ${isChecked ? 'line-through' : ''}`}>${item.sale_price.toFixed(2)}</span>
<span className="text-sm font-semibold text-danger-600">${item.sale_price.toFixed(2)}</span> {item.estimated_price && item.estimated_price > item.sale_price && (
{item.estimated_price && item.estimated_price > item.sale_price && ( <span className="text-xs text-surface-400 line-through">
<span className="text-xs text-surface-400 line-through"> ${item.estimated_price.toFixed(2)}
${item.estimated_price.toFixed(2)} </span>
</span> )}
)} </div>
</div> ) : item.estimated_price ? (
) : item.estimated_price ? ( <span className={`text-sm font-medium text-surface-700 ${isChecked ? 'line-through' : ''}`}>
<span className="text-sm font-medium text-surface-700"> ${item.estimated_price.toFixed(2)}
${item.estimated_price.toFixed(2)} </span>
</span> ) : null}
) : null} </div>
</div> </li>
</li> )
))} })}
</ul> </ul>
</Card> </Card>
))} ))}