diff --git a/backend/app/api/meals.py b/backend/app/api/meals.py index 42fd7c3..9f59cf0 100644 --- a/backend/app/api/meals.py +++ b/backend/app/api/meals.py @@ -12,7 +12,9 @@ from app.models import ( ) from app.schemas import ( MealPlanResponse, MealPlanCreate, - MealPlanItemResponse, VoteRequest, VoteResponse + MealPlanItemResponse, VoteRequest, VoteResponse, + FillEmptySlotsRequest, FillEmptySlotsResult, + FilledSlot, FailedSlot, ) from app.security import require_session from app.services import approval as approval_service @@ -431,6 +433,108 @@ def generate_single_item( return {"message": "Meal generated", "item": new_item} +@router.post("/{meal_plan_id}/fill-empty-slots", response_model=FillEmptySlotsResult) +def fill_empty_slots( + meal_plan_id: UUID, + payload: FillEmptySlotsRequest, + db: Session = Depends(get_db), +): + """Fill every empty slot in the plan whose meal_type is in the + request's `meal_types`. Returns a per-slot report (filled vs + failed) so the UI can show "12 of 21 filled, 9 failed — recipe + library exhausted". + + Failure model: per-slot. The endpoint never aborts mid-batch on + a single failure; it commits what succeeded and reports the + rest. This matches the user's chosen model (partial-success + with detailed report). + """ + plan = db.query(MealPlan).filter(MealPlan.id == meal_plan_id).first() + if not plan: + raise HTTPException(status_code=404, detail="Meal plan not found") + + # Normalise and validate the requested meal_types. + requested: list[str] = [] + for mt in payload.meal_types: + try: + canonical = MealType[mt.upper()].value + except KeyError: + return FillEmptySlotsResult( + filled=[], + failed=[FailedSlot(day_of_week=0, meal_type=mt, reason=f"Unknown meal_type: {mt}")], + ) + if canonical not in requested: + requested.append(canonical) + + if not requested: + return FillEmptySlotsResult(filled=[], failed=[]) + + all_recipes = db.query(Recipe).all() + if not all_recipes: + # No recipes at all — every requested slot fails. + return FillEmptySlotsResult( + filled=[], + failed=[ + FailedSlot(day_of_week=d, meal_type=mt, reason="No recipes available") + for d in range(1, 8) + for mt in requested + ], + ) + + used_ids: set = {i.recipe_id for i in plan.items if i.recipe_id is not None} + + filled: list[FilledSlot] = [] + failed: list[FailedSlot] = [] + + for day in range(1, 8): + for mt in requested: + # Skip already-occupied slots. + existing = ( + db.query(MealPlanItem) + .filter( + MealPlanItem.meal_plan_id == meal_plan_id, + MealPlanItem.day_of_week == day, + MealPlanItem.meal_type == mt, + ) + .first() + ) + if existing: + continue # not a failure, just nothing to do + + # Pick a recipe; prefer un-used, fall back to any. + available = [r for r in all_recipes if r.id not in used_ids] + pool = available if available else all_recipes + recipe = random.choice(pool) + + new_item = MealPlanItem( + meal_plan_id=meal_plan_id, + recipe_id=recipe.id, + day_of_week=day, + meal_type=MealType[mt.upper()], + approval_status=MealPlanItemStatus.pending, + ) + db.add(new_item) + try: + db.flush() + used_ids.add(recipe.id) + filled.append(FilledSlot( + day_of_week=day, + meal_type=mt, + item=MealPlanItemResponse.model_validate(new_item), + )) + except Exception as exc: + db.rollback() + used_ids = {i.recipe_id for i in plan.items if i.recipe_id is not None} + failed.append(FilledSlot if False else FailedSlot( + day_of_week=day, + meal_type=mt, + reason=str(exc) or "Insert failed", + )) + + db.commit() + return FillEmptySlotsResult(filled=filled, failed=failed) + + @router.put("/items/{item_id}/move") def move_meal_item( item_id: UUID, diff --git a/backend/app/api/pantry.py b/backend/app/api/pantry.py index 6371655..96c501d 100644 --- a/backend/app/api/pantry.py +++ b/backend/app/api/pantry.py @@ -2,7 +2,13 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session from app.database import get_db from app.models import HomePantry, Ingredient, FamilyProfile -from app.schemas import HomePantryResponse, HomePantryCreate +from app.schemas import ( + HomePantryResponse, + HomePantryCreate, + HomePantryBulkCreate, + HomePantryBulkResult, + HomePantryBulkResultItem, +) from app.security import require_session from uuid import UUID from typing import List @@ -55,6 +61,78 @@ def add_pantry_item(item: HomePantryCreate, db: Session = Depends(get_db)): return db_item +@router.post("/bulk", response_model=HomePantryBulkResult, dependencies=[Depends(require_session)]) +def add_pantry_items_bulk(payload: HomePantryBulkCreate, db: Session = Depends(get_db)): + """Add or upsert many pantry items in a single call. + + Used by the Shopping List "Add checked to pantry" bulk action. Each + item follows the same semantics as POST /api/pantry (insert or + overwrite). Items missing an ingredient link or with an + unknown ingredient id are reported as "skipped" rather than + aborting the batch, so the user gets a precise count of what + actually went into their pantry. + """ + profile = db.query(FamilyProfile).first() + if not profile: + raise HTTPException(status_code=404, detail="Family profile not found") + + results: List[HomePantryBulkResultItem] = [] + added = updated = skipped = 0 + + for item in payload.items: + # Validate the ingredient id before touching the DB. + ingredient = db.query(Ingredient).filter(Ingredient.id == item.ingredient_id).first() + if not ingredient: + results.append(HomePantryBulkResultItem( + ingredient_id=item.ingredient_id, + status="skipped", + reason="Unknown ingredient", + )) + skipped += 1 + continue + + existing = db.query(HomePantry).filter( + HomePantry.family_profile_id == profile.id, + HomePantry.ingredient_id == item.ingredient_id, + ).first() + + if existing: + existing.quantity = item.quantity + existing.unit = item.unit + existing.expires_at = item.expires_at + db.flush() + results.append(HomePantryBulkResultItem( + ingredient_id=item.ingredient_id, + id=existing.id, + status="updated", + )) + updated += 1 + else: + db_item = HomePantry( + family_profile_id=profile.id, + ingredient_id=item.ingredient_id, + quantity=item.quantity, + unit=item.unit, + expires_at=item.expires_at, + ) + db.add(db_item) + db.flush() + results.append(HomePantryBulkResultItem( + ingredient_id=item.ingredient_id, + id=db_item.id, + status="added", + )) + added += 1 + + db.commit() + return HomePantryBulkResult( + added=added, + updated=updated, + skipped=skipped, + results=results, + ) + + @router.delete("/{item_id}", dependencies=[Depends(require_session)]) def remove_pantry_item(item_id: UUID, db: Session = Depends(get_db)): item = db.query(HomePantry).filter(HomePantry.id == item_id).first() diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 5e7e0e8..0ecc63d 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -284,6 +284,64 @@ class HomePantryCreate(HomePantryBase): pass +class HomePantryBulkCreate(BaseModel): + """Request body for POST /api/pantry/bulk. Accepts a list of items to + add in one call; each item follows the same upsert semantics as + HomePantryCreate (insert or overwrite qty/unit/expires).""" + items: List[HomePantryCreate] + + +class HomePantryBulkResultItem(BaseModel): + ingredient_id: UUID + status: str # "added" | "updated" | "skipped" + id: Optional[UUID] = None + reason: Optional[str] = None + + +class HomePantryBulkResult(BaseModel): + """Response body for POST /api/pantry/bulk. Reports per-item + outcomes so the UI can show a precise toast ("Added 8 items, 1 + skipped — no ingredient link"). Total counts are derived for + convenience.""" + added: int + updated: int + skipped: int + results: List[HomePantryBulkResultItem] + + +class FillEmptySlotsRequest(BaseModel): + """Request body for POST /api/meals/{id}/fill-empty-slots. The + caller selects which meal types to fill (dinner only, or all + three). Days 1-7 are filled automatically; the backend iterates + in day order then meal_type order.""" + meal_types: List[str] = Field( + default_factory=lambda: ["breakfast", "lunch", "dinner"], + description="Subset of {breakfast, lunch, dinner} to fill.", + ) + + +class FilledSlot(BaseModel): + day_of_week: int + meal_type: str + item: MealPlanItemResponse + + +class FailedSlot(BaseModel): + day_of_week: int + meal_type: str + reason: str + + +class FillEmptySlotsResult(BaseModel): + """Response body for POST /api/meals/{id}/fill-empty-slots. + Reports each slot as either 'filled' (with the new MealPlanItem) + or 'failed' (with a human-readable reason). Partial success is + the model: the caller decides whether to retry the failed + slots.""" + filled: List[FilledSlot] + failed: List[FailedSlot] + + class FeedbackBase(BaseModel): rating: Optional[int] = Field(None, ge=1, le=5) never_suggest: bool = False diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 41f54b1..223967c 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -50,6 +50,8 @@ export const mealPlannerApi = { deleteItem: (itemId: string) => api.delete(`/meals/items/${itemId}`), generateItem: (mealPlanId: string, dayOfWeek: number, mealType: string) => api.post(`/meals/${mealPlanId}/generate-item`, null, { params: { day_of_week: dayOfWeek, meal_type: mealType } }), + fillEmptySlots: (mealPlanId: string, mealTypes: string[]) => + api.post(`/meals/${mealPlanId}/fill-empty-slots`, { meal_types: mealTypes }), }, pantry: { @@ -57,6 +59,8 @@ export const mealPlannerApi = { add: (data: any) => api.post('/pantry', data), update: (id: string, data: any) => api.put(`/pantry/${id}`, data), remove: (id: string) => api.delete(`/pantry/${id}`), + addBulk: (items: Array<{ ingredient_id: string; quantity?: number; unit?: string }>) => + api.post('/pantry/bulk', { items }), }, shoppingList: { diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index f8404a4..96a93be 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -8,6 +8,7 @@ import { import toast from 'react-hot-toast' import { showToast, showApiError } from '../lib/toast' import { isoMonday, parseIsoDate, shiftIsoDate, formatIsoDate } from '../lib/utils' +import { ChevronDown } from 'lucide-react' import { DragDropContext, Droppable, @@ -317,6 +318,36 @@ export default function Dashboard() { const navigateWeek = (next: string) => { setSearchParams(next === isoMonday() ? {} : { week: next }, { replace: true }) } + const [planningWeek, setPlanningWeek] = useState(false) + const [planMenuOpen, setPlanMenuOpen] = useState(false) + + async function handlePlanWeek(mealTypes: string[]) { + if (!mealPlan || planningWeek) return + setPlanMenuOpen(false) + setPlanningWeek(true) + try { + const res = await mealPlannerApi.meals.fillEmptySlots(mealPlan.id, mealTypes) + const data = res.data as { filled: unknown[]; failed: { reason: string }[] } + const filledCount = data.filled.length + const failedCount = data.failed.length + const label = mealTypes.length === 1 && mealTypes[0] === 'dinner' ? 'dinners' : 'meal slots' + if (filledCount === 0 && failedCount === 0) { + showToast.success(`No empty ${label} to fill`) + } else if (failedCount === 0) { + showToast.success(`Planned ${filledCount} ${label}`) + } else { + const reason = data.failed[0]?.reason ?? 'Unknown' + showToast.error( + `Planned ${filledCount} of ${filledCount + failedCount} ${label} — ${failedCount} failed (e.g. ${reason})`, + ) + } + queryClient.invalidateQueries({ queryKey: ['mealPlan', weekStart] }) + } catch (err) { + showApiError(err, 'Failed to plan the week') + } finally { + setPlanningWeek(false) + } + } const { data: mealPlan, isLoading } = useQuery({ queryKey: ['mealPlan', weekStart], queryFn: () => mealPlannerApi.meals.getPlanned(weekStart).then(r => r.data), @@ -470,6 +501,46 @@ export default function Dashboard() { +
+ + {planMenuOpen && ( +
+ + +
+ )} +
= { produce: 'Produce', @@ -127,6 +128,55 @@ export default function ShoppingListPage() { const clearAll = () => setChecked(new Set()) + const [addingToPantry, setAddingToPantry] = useState(false) + const addCheckedToPantry = async () => { + if (!shoppingList || checked.size === 0 || addingToPantry) return + const itemsToAdd = shoppingList.items.filter( + i => i.ingredient_id && checked.has(i.ingredient_id), + ) + if (itemsToAdd.length === 0) return + setAddingToPantry(true) + try { + const res = await mealPlannerApi.pantry.addBulk( + itemsToAdd.map(i => ({ + ingredient_id: i.ingredient_id!, + quantity: i.quantity, + unit: i.unit, + })), + ) + const data = res.data as { added: number; updated: number; skipped: number } + const parts: string[] = [] + if (data.added) parts.push(`added ${data.added}`) + if (data.updated) parts.push(`updated ${data.updated}`) + if (data.skipped) parts.push(`skipped ${data.skipped}`) + const summary = parts.length ? parts.join(', ') : 'no changes' + if (data.added || data.updated) { + showToast.success(`Pantry: ${summary}`) + // Drop only the items that actually went into the pantry from + // the checked set; any skipped items stay checked so the user + // can see what failed. + const skippedIds = new Set( + shoppingList.items + .filter(i => i.ingredient_id && !itemsToAdd.some(j => j.ingredient_id === i.ingredient_id)) + .map(i => i.ingredient_id!), + ) + setChecked(prev => { + const next = new Set(prev) + for (const id of Array.from(next)) { + if (!skippedIds.has(id)) next.delete(id) + } + return next + }) + } else { + showToast.error(`Pantry: ${summary}`) + } + } catch (err) { + showApiError(err, 'Failed to add items to pantry') + } finally { + setAddingToPantry(false) + } + } + const progress = shoppingList && shoppingList.items.length > 0 ? Math.round( @@ -234,6 +284,17 @@ export default function ShoppingListPage() { {progress > 0 && ( {progress}% complete )} + {checked.size > 0 && ( + + )} {checked.size > 0 && (