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 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,
+1 -1
View File
@@ -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
+5 -2
View File
@@ -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,
)
+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),
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: {
+86 -9
View File
@@ -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 }: {
</span>
)}
</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>
@@ -107,10 +126,16 @@ function MealSlot({
dayIndex,
mealType,
item,
onApprove,
onDeny,
onGenerate,
}: {
dayIndex: number
mealType: string
item?: MealPlanItem
onApprove?: (itemId: string) => void
onDeny?: (itemId: string) => void
onGenerate?: (dayIndex: number, mealType: string) => void
}) {
const droppableId = `slot-${dayIndex}-${mealType}`
@@ -137,14 +162,23 @@ function MealSlot({
item={item}
dragHandleProps={dragProvided.dragHandleProps}
isDragging={dragSnapshot.isDragging}
onApprove={onApprove}
onDeny={onDeny}
/>
</div>
)}
</Draggable>
) : (
/* empty slot placeholder — invisible but droppable */
<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">Drop here</span>
<div className="h-16 rounded-xl border-2 border-dashed border-surface-200 flex flex-col items-center justify-center gap-1">
<span className="text-xs text-surface-300">Empty</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>
)}
{provided.placeholder}
@@ -160,9 +194,15 @@ function MealSlot({
function DayColumn({
dayIndex,
items,
onApprove,
onDeny,
onGenerate,
}: {
dayIndex: number
items: MealPlanItem[]
onApprove?: (itemId: string) => void
onDeny?: (itemId: string) => void
onGenerate?: (dayIndex: number, mealType: string) => void
}) {
const today = new Date().getDay()
const isToday = today === (dayIndex + 1) % 7
@@ -190,6 +230,9 @@ function DayColumn({
dayIndex={dayIndex}
mealType={mealType}
item={item}
onApprove={onApprove}
onDeny={onDeny}
onGenerate={onGenerate}
/>
</div>
)
@@ -289,6 +332,37 @@ export default function Dashboard() {
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 (!mealPlan) {
@@ -359,11 +433,14 @@ export default function Dashboard() {
<DragDropContext onDragEnd={onDragEnd}>
<div className="grid grid-cols-7 gap-2">
{itemsByDay.map((items, index) => (
<DayColumn
key={index}
dayIndex={index}
items={items}
/>
<DayColumn
key={index}
dayIndex={index}
items={items}
onApprove={handleApprove}
onDeny={handleDeny}
onGenerate={handleGenerate}
/>
))}
</div>
</DragDropContext>