diff --git a/backend/app/api/meals.py b/backend/app/api/meals.py index b901788..4829385 100644 --- a/backend/app/api/meals.py +++ b/backend/app/api/meals.py @@ -19,6 +19,9 @@ from app.services import approval as approval_service from uuid import UUID from typing import List, Optional from datetime import datetime, timedelta +from uuid import UUID +from typing import List, Optional +import random 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} +@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") def move_meal_item( item_id: UUID, diff --git a/backend/app/services/planner/config.py b/backend/app/services/planner/config.py index a3e8e5f..185dcc3 100644 --- a/backend/app/services/planner/config.py +++ b/backend/app/services/planner/config.py @@ -28,7 +28,7 @@ class PlannerConfig: # Set selection 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_cuisine: float = 0.10 # diversity penalty per shared-cuisine pair diff --git a/backend/app/services/planner/generate.py b/backend/app/services/planner/generate.py index 28eb00f..b1eaf10 100644 --- a/backend/app/services/planner/generate.py +++ b/backend/app/services/planner/generate.py @@ -177,11 +177,14 @@ def generate_meal_plan( db.flush() 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( meal_plan_id=plan.id, recipe_id=scored_recipe.recipe_id, - day_of_week=index + 1, # Mon=1, Tue=2, Wed=3 by default - meal_type=MealType.DINNER, + day_of_week=day, + meal_type=meal_type, approval_status=MealPlanItemStatus.pending, estimated_cost=scored_recipe.cost.total_cost, ) diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 13cee86..1a24c65 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -43,6 +43,10 @@ export const mealPlannerApi = { 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}`), 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: { diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index c2b1823..a7c5c2e 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -31,10 +31,12 @@ const MEAL_TYPES = ['breakfast', 'lunch', 'dinner'] as const /* ------------------------------------------------------------------ */ /* MealCard (draggable) */ /* ------------------------------------------------------------------ */ -function MealCard({ item, dragHandleProps, isDragging }: { +function MealCard({ item, dragHandleProps, isDragging, onApprove, onDeny }: { item: MealPlanItem dragHandleProps?: DraggableProvidedDragHandleProps | null isDragging?: boolean + onApprove?: (itemId: string) => void + onDeny?: (itemId: string) => void }) { const totalTime = item.recipe?.total_time_minutes ?? (item.recipe?.prep_time_minutes || 0) + (item.recipe?.cook_time_minutes || 0) @@ -94,6 +96,23 @@ function MealCard({ item, dragHandleProps, isDragging }: { )} + {/* Approve / Deny buttons */} + {item.approval_status === 'pending' && onApprove && onDeny && ( +