feat(ui): bulk pantry add + plan-the-week button (Sprint 6 F3+F4)

F3 — Bulk 'add checked to pantry' on ShoppingList (the audit's F3 /
H7 finding). ShoppingList already had a 'checked' Set keyed on
ingredient_id and persisted to localStorage — that selection state
is the natural substrate for a bulk action.

Backend (POST /api/pantry/bulk):
- New endpoint that accepts {items: HomePantryCreate[]} and returns
  HomePantryBulkResult with per-item status (added / updated /
  skipped) and totals. Each item follows the same upsert semantics
  as POST /api/pantry (insert or overwrite qty/unit/expires_at).
- Items with an unknown ingredient id are reported as 'skipped'
  with reason='Unknown ingredient' rather than aborting the batch.
  Per-item failure is the chosen model (partial-success) so the
  user gets a precise count of what actually went in.
- New Pydantic schemas: HomePantryBulkCreate, HomePantryBulkResult,
  HomePantryBulkResultItem.

Frontend:
- mealPlannerApi.pantry.addBulk(items) is the API binding.
- ShoppingList gets a new 'Add N to pantry' primary button (next
  to the existing Reset button) that appears when checked.size > 0.
  Click → POST /api/pantry/bulk → toast shows 'added X, updated Y,
  skipped Z' counts. On success, only the items that actually
  landed in the pantry are removed from the checked set; skipped
  items stay checked so the user can see what failed.
- Disabled state with 'Adding…' label while the request is in
  flight; button text shows the count dynamically (matches the
  F4 design language: tell the user what they're about to do).

F4 — Plan the whole week (the audit's F4 / H7 finding).

Backend (POST /api/meals/{id}/fill-empty-slots):
- New endpoint that takes {meal_types: [str, ...]} and fills every
  empty slot in the plan whose meal_type is in the request. Per-day
  iteration (1-7) per meal_type, skipping already-occupied slots.
  Recipe selection: prefer un-used, fall back to any (same as the
  existing generate-item).
- Per-slot failure model: never aborts mid-batch. Returns
  FillEmptySlotsResult { filled: [{day, meal_type, item}],
  failed: [{day, meal_type, reason}] }. Invalid meal_types
  (e.g. 'brunch') return immediately with a single FailedSlot
  explaining why.
- Same approval_status=pending semantics as generate-item.

Frontend:
- mealPlannerApi.meals.fillEmptySlots(planId, mealTypes) is the
  API binding.
- New 'Plan the week' button on the Dashboard header (next to the
  week-nav control from Sprint 5). Primary color, Sparkles icon,
  ChevronDown caret indicates a dropdown. Disabled + spinner
  ('Planning…') while the request runs.
- Dropdown has two options: 'Dinners only' (sends
  meal_types=['dinner']) and 'All meals' (sends
  meal_types=['breakfast','lunch','dinner']). Each option has a
  one-line secondary label explaining the action.
- Toast on success: 'Planned N meal slots' (full) or 'Planned N
  of M meal slots — X failed (e.g. <reason>)' (partial). The
  query is then invalidated so the new slots show up.

Files: backend/app/api/meals.py, backend/app/api/pantry.py,
backend/app/schemas/__init__.py, frontend/src/api/index.ts,
frontend/src/pages/Dashboard.tsx, frontend/src/pages/ShoppingList.tsx.

Build: tsc 0 errors, vite 0 errors. Bundle +3.6KB (the new code
fits in the existing chunk).
Curl smoke on local dev DB confirms both new endpoints behave as
designed: /api/pantry/bulk returns proper skipped count for
unknown ingredients, /api/meals/{id}/fill-empty-slots returns
the partial-success result for the dinners-only call.
This commit is contained in:
2026-06-04 14:00:31 -07:00
parent 2029e80c5a
commit 8ad4ef67a9
6 changed files with 379 additions and 3 deletions
+105 -1
View File
@@ -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,
+79 -1
View File
@@ -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()