Public Access
fix: shopping list ingredient UUID parsing for prices/aisles; feat: interactive checkboxes with localStorage persistence
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,9 +135,24 @@ export default function ShoppingListPage() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="secondary" icon={<Printer className="w-4 h-4" />} onClick={() => window.print()}>
|
||||
Print List
|
||||
</Button>
|
||||
<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 */}
|
||||
@@ -146,44 +211,54 @@ export default function ShoppingListPage() {
|
||||
<Badge variant="neutral">{items.length} items</Badge>
|
||||
</div>
|
||||
<ul className="divide-y divide-surface-200">
|
||||
{items.map((item, idx) => (
|
||||
<li
|
||||
key={idx}
|
||||
className="px-5 py-3.5 flex items-center justify-between hover:bg-surface-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-5 h-5 rounded border-2 border-surface-300 flex-shrink-0" />
|
||||
<div>
|
||||
<span className="text-sm font-medium text-surface-900">{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>}
|
||||
{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 ${isChecked ? 'opacity-60' : ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<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 ${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 className="text-right flex items-center gap-3">
|
||||
{item.quantity && (
|
||||
<span className="text-sm text-surface-500">
|
||||
{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>
|
||||
{item.estimated_price && item.estimated_price > item.sale_price && (
|
||||
<span className="text-xs text-surface-400 line-through">
|
||||
${item.estimated_price.toFixed(2)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : item.estimated_price ? (
|
||||
<span className="text-sm font-medium text-surface-700">
|
||||
${item.estimated_price.toFixed(2)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
<div className="text-right flex items-center gap-3">
|
||||
{item.quantity && (
|
||||
<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 ${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)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : item.estimated_price ? (
|
||||
<span className={`text-sm font-medium text-surface-700 ${isChecked ? 'line-through' : ''}`}>
|
||||
${item.estimated_price.toFixed(2)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user