feat: 21 meals per week (3/day) + approve/deny + generate single meal

Backend:
- Planner config: set_size=21 (was 3), top_k=30 (was 20)
- generate.py: distribute 21 recipes across 7 days × 3 meal types
- meals.py: add POST /items/{id}/approve, /items/{id}/deny
- meals.py: add POST /{plan_id}/generate-item for empty slots

Frontend:
- Dashboard: Approve/Deny buttons on pending meal cards
- Dashboard: Generate button in empty meal slots
- API client: approveItem, denyItem, generateItem methods

Build: TypeScript compiles clean, Python syntax verified.
This commit is contained in:
2026-05-15 11:44:04 -07:00
parent ddcdb962ca
commit 7b5e65087e
5 changed files with 170 additions and 12 deletions
+74
View File
@@ -19,6 +19,9 @@ from app.services import approval as approval_service
from uuid import UUID from uuid import UUID
from typing import List, Optional from typing import List, Optional
from datetime import datetime, timedelta from datetime import datetime, timedelta
from uuid import UUID
from typing import List, Optional
import random
router = APIRouter() router = APIRouter()
@@ -323,6 +326,77 @@ def swap_meal_item(item_id: UUID, new_recipe_id: UUID, db: Session = Depends(get
return {"message": "Meal swapped", "item": item} return {"message": "Meal swapped", "item": item}
@router.post("/items/{item_id}/approve")
def approve_meal_item(item_id: UUID, db: Session = Depends(get_db)):
"""Directly approve a meal plan item from the dashboard."""
item = db.query(MealPlanItem).filter(MealPlanItem.id == item_id).first()
if not item:
raise HTTPException(status_code=404, detail="Meal plan item not found")
item.approval_status = MealPlanItemStatus.approved
db.commit()
return {"message": "Meal approved", "item": item}
@router.post("/items/{item_id}/deny")
def deny_meal_item(item_id: UUID, db: Session = Depends(get_db)):
"""Directly deny a meal plan item from the dashboard."""
item = db.query(MealPlanItem).filter(MealPlanItem.id == item_id).first()
if not item:
raise HTTPException(status_code=404, detail="Meal plan item not found")
item.approval_status = MealPlanItemStatus.denied
db.commit()
return {"message": "Meal denied", "item": item}
@router.post("/{meal_plan_id}/generate-item")
def generate_single_item(
meal_plan_id: UUID,
day_of_week: int = Query(..., ge=1, le=7),
meal_type: str = Query(...),
db: Session = Depends(get_db),
):
"""Generate a single meal for an empty slot."""
plan = db.query(MealPlan).filter(MealPlan.id == meal_plan_id).first()
if not plan:
raise HTTPException(status_code=404, detail="Meal plan not found")
# Verify slot is empty
existing = (
db.query(MealPlanItem)
.filter(
MealPlanItem.meal_plan_id == meal_plan_id,
MealPlanItem.day_of_week == day_of_week,
MealPlanItem.meal_type == meal_type,
)
.first()
)
if existing:
raise HTTPException(status_code=400, detail="Slot already occupied")
# Get all recipes, score them, pick the best not already in this plan
all_recipes = db.query(Recipe).all()
used_ids = {i.recipe_id for i in plan.items if i.recipe_id is not None}
# Simple heuristic: pick a random un-used recipe (we can improve scoring later)
available = [r for r in all_recipes if r.id not in used_ids]
if not available:
available = all_recipes # reuse if all recipes are in play
recipe = random.choice(available)
new_item = MealPlanItem(
meal_plan_id=meal_plan_id,
recipe_id=recipe.id,
day_of_week=day_of_week,
meal_type=MealType[meal_type.upper()],
approval_status=MealPlanItemStatus.pending,
)
db.add(new_item)
db.commit()
db.refresh(new_item)
return {"message": "Meal generated", "item": new_item}
@router.put("/items/{item_id}/move") @router.put("/items/{item_id}/move")
def move_meal_item( def move_meal_item(
item_id: UUID, item_id: UUID,
+1 -1
View File
@@ -28,7 +28,7 @@ class PlannerConfig:
# Set selection # Set selection
top_k: int = 20 # how many feasible recipes to enumerate over top_k: int = 20 # how many feasible recipes to enumerate over
set_size: int = 3 # 3 dinners/week set_size: int = 21 # 21 meals/week (3 per day × 7 days)
p_protein: float = 0.15 # diversity penalty per shared-protein pair p_protein: float = 0.15 # diversity penalty per shared-protein pair
p_cuisine: float = 0.10 # diversity penalty per shared-cuisine pair p_cuisine: float = 0.10 # diversity penalty per shared-cuisine pair
+5 -2
View File
@@ -177,11 +177,14 @@ def generate_meal_plan(
db.flush() db.flush()
for index, scored_recipe in enumerate(chosen): for index, scored_recipe in enumerate(chosen):
day = (index % 7) + 1 # 1..7 (Mon..Sun)
meal_type_index = index // 7 # 0=breakfast, 1=lunch, 2=dinner
meal_type = [MealType.BREAKFAST, MealType.LUNCH, MealType.DINNER][meal_type_index]
item = MealPlanItem( item = MealPlanItem(
meal_plan_id=plan.id, meal_plan_id=plan.id,
recipe_id=scored_recipe.recipe_id, recipe_id=scored_recipe.recipe_id,
day_of_week=index + 1, # Mon=1, Tue=2, Wed=3 by default day_of_week=day,
meal_type=MealType.DINNER, meal_type=meal_type,
approval_status=MealPlanItemStatus.pending, approval_status=MealPlanItemStatus.pending,
estimated_cost=scored_recipe.cost.total_cost, estimated_cost=scored_recipe.cost.total_cost,
) )
+4
View File
@@ -43,6 +43,10 @@ export const mealPlannerApi = {
submitVote: (itemId: string, token: string, data: any) => api.post(`/meals/items/${itemId}/vote/${token}`, data), submitVote: (itemId: string, token: string, data: any) => api.post(`/meals/items/${itemId}/vote/${token}`, data),
swapItem: (itemId: string, newRecipeId: string) => api.post(`/meals/items/${itemId}/swap?new_recipe_id=${newRecipeId}`), swapItem: (itemId: string, newRecipeId: string) => api.post(`/meals/items/${itemId}/swap?new_recipe_id=${newRecipeId}`),
moveItem: (itemId: string, newDayOfWeek: number, newMealType: string) => api.put(`/meals/items/${itemId}/move`, null, { params: { new_day_of_week: newDayOfWeek, new_meal_type: newMealType } }), moveItem: (itemId: string, newDayOfWeek: number, newMealType: string) => api.put(`/meals/items/${itemId}/move`, null, { params: { new_day_of_week: newDayOfWeek, new_meal_type: newMealType } }),
approveItem: (itemId: string) => api.post(`/meals/items/${itemId}/approve`),
denyItem: (itemId: string) => api.post(`/meals/items/${itemId}/deny`),
generateItem: (mealPlanId: string, dayOfWeek: number, mealType: string) =>
api.post(`/meals/${mealPlanId}/generate-item`, null, { params: { day_of_week: dayOfWeek, meal_type: mealType } }),
}, },
pantry: { pantry: {
+86 -9
View File
@@ -31,10 +31,12 @@ const MEAL_TYPES = ['breakfast', 'lunch', 'dinner'] as const
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* MealCard (draggable) */ /* MealCard (draggable) */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
function MealCard({ item, dragHandleProps, isDragging }: { function MealCard({ item, dragHandleProps, isDragging, onApprove, onDeny }: {
item: MealPlanItem item: MealPlanItem
dragHandleProps?: DraggableProvidedDragHandleProps | null dragHandleProps?: DraggableProvidedDragHandleProps | null
isDragging?: boolean isDragging?: boolean
onApprove?: (itemId: string) => void
onDeny?: (itemId: string) => void
}) { }) {
const totalTime = item.recipe?.total_time_minutes ?? const totalTime = item.recipe?.total_time_minutes ??
(item.recipe?.prep_time_minutes || 0) + (item.recipe?.cook_time_minutes || 0) (item.recipe?.prep_time_minutes || 0) + (item.recipe?.cook_time_minutes || 0)
@@ -94,6 +96,23 @@ function MealCard({ item, dragHandleProps, isDragging }: {
</span> </span>
)} )}
</div> </div>
{/* Approve / Deny buttons */}
{item.approval_status === 'pending' && onApprove && onDeny && (
<div className="flex items-center gap-1 mt-1.5">
<button
onClick={(e) => { e.stopPropagation(); onApprove(item.id) }}
className="text-[10px] px-2 py-0.5 rounded bg-success-50 text-success-700 hover:bg-success-100 font-medium transition-colors"
>
Approve
</button>
<button
onClick={(e) => { e.stopPropagation(); onDeny(item.id) }}
className="text-[10px] px-2 py-0.5 rounded bg-danger-50 text-danger-700 hover:bg-danger-100 font-medium transition-colors"
>
Deny
</button>
</div>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -107,10 +126,16 @@ function MealSlot({
dayIndex, dayIndex,
mealType, mealType,
item, item,
onApprove,
onDeny,
onGenerate,
}: { }: {
dayIndex: number dayIndex: number
mealType: string mealType: string
item?: MealPlanItem item?: MealPlanItem
onApprove?: (itemId: string) => void
onDeny?: (itemId: string) => void
onGenerate?: (dayIndex: number, mealType: string) => void
}) { }) {
const droppableId = `slot-${dayIndex}-${mealType}` const droppableId = `slot-${dayIndex}-${mealType}`
@@ -137,14 +162,23 @@ function MealSlot({
item={item} item={item}
dragHandleProps={dragProvided.dragHandleProps} dragHandleProps={dragProvided.dragHandleProps}
isDragging={dragSnapshot.isDragging} isDragging={dragSnapshot.isDragging}
onApprove={onApprove}
onDeny={onDeny}
/> />
</div> </div>
)} )}
</Draggable> </Draggable>
) : ( ) : (
/* empty slot placeholder — invisible but droppable */ <div className="h-16 rounded-xl border-2 border-dashed border-surface-200 flex flex-col items-center justify-center gap-1">
<div className="h-16 rounded-xl border-2 border-dashed border-surface-200 flex items-center justify-center"> <span className="text-xs text-surface-300">Empty</span>
<span className="text-xs text-surface-300">Drop here</span> {onGenerate && (
<button
onClick={() => onGenerate(dayIndex, mealType)}
className="text-[10px] px-2 py-0.5 rounded bg-primary-50 text-primary-700 hover:bg-primary-100 font-medium transition-colors"
>
Generate
</button>
)}
</div> </div>
)} )}
{provided.placeholder} {provided.placeholder}
@@ -160,9 +194,15 @@ function MealSlot({
function DayColumn({ function DayColumn({
dayIndex, dayIndex,
items, items,
onApprove,
onDeny,
onGenerate,
}: { }: {
dayIndex: number dayIndex: number
items: MealPlanItem[] items: MealPlanItem[]
onApprove?: (itemId: string) => void
onDeny?: (itemId: string) => void
onGenerate?: (dayIndex: number, mealType: string) => void
}) { }) {
const today = new Date().getDay() const today = new Date().getDay()
const isToday = today === (dayIndex + 1) % 7 const isToday = today === (dayIndex + 1) % 7
@@ -190,6 +230,9 @@ function DayColumn({
dayIndex={dayIndex} dayIndex={dayIndex}
mealType={mealType} mealType={mealType}
item={item} item={item}
onApprove={onApprove}
onDeny={onDeny}
onGenerate={onGenerate}
/> />
</div> </div>
) )
@@ -289,6 +332,37 @@ export default function Dashboard() {
handleDrop(itemId, parseInt(dayStr, 10), typeStr) handleDrop(itemId, parseInt(dayStr, 10), typeStr)
} }
async function handleApprove(itemId: string) {
try {
await mealPlannerApi.meals.approveItem(itemId)
toast.success('Meal approved')
queryClient.invalidateQueries({ queryKey: ['mealPlan'] })
} catch (err: any) {
toast.error(err?.response?.data?.detail || 'Failed to approve meal')
}
}
async function handleDeny(itemId: string) {
try {
await mealPlannerApi.meals.denyItem(itemId)
toast.success('Meal denied')
queryClient.invalidateQueries({ queryKey: ['mealPlan'] })
} catch (err: any) {
toast.error(err?.response?.data?.detail || 'Failed to deny meal')
}
}
async function handleGenerate(dayIndex: number, mealType: string) {
if (!mealPlan) return
try {
await mealPlannerApi.meals.generateItem(mealPlan.id, dayIndex + 1, mealType)
toast.success('Meal generated')
queryClient.invalidateQueries({ queryKey: ['mealPlan'] })
} catch (err: any) {
toast.error(err?.response?.data?.detail || 'Failed to generate meal')
}
}
if (isLoading) return <DashboardSkeleton /> if (isLoading) return <DashboardSkeleton />
if (!mealPlan) { if (!mealPlan) {
@@ -359,11 +433,14 @@ export default function Dashboard() {
<DragDropContext onDragEnd={onDragEnd}> <DragDropContext onDragEnd={onDragEnd}>
<div className="grid grid-cols-7 gap-2"> <div className="grid grid-cols-7 gap-2">
{itemsByDay.map((items, index) => ( {itemsByDay.map((items, index) => (
<DayColumn <DayColumn
key={index} key={index}
dayIndex={index} dayIndex={index}
items={items} items={items}
/> onApprove={handleApprove}
onDeny={handleDeny}
onGenerate={handleGenerate}
/>
))} ))}
</div> </div>
</DragDropContext> </DragDropContext>