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() {

- +
+ {progress > 0 && ( + {progress}% complete + )} + {checked.size > 0 && ( + + )} + +
{/* 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} +
    + + ) + })}
))}