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
+58
View File
@@ -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