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
|
- **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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user