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
+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>