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 ( from app.schemas import (
MealPlanResponse, MealPlanCreate, MealPlanResponse, MealPlanCreate,
MealPlanItemResponse, VoteRequest, VoteResponse MealPlanItemResponse, VoteRequest, VoteResponse,
FillEmptySlotsRequest, FillEmptySlotsResult,
FilledSlot, FailedSlot,
) )
from app.security import require_session from app.security import require_session
from app.services import approval as approval_service from app.services import approval as approval_service
@@ -431,6 +433,108 @@ def generate_single_item(
return {"message": "Meal generated", "item": new_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") @router.put("/items/{item_id}/move")
def move_meal_item( def move_meal_item(
item_id: UUID, item_id: UUID,
+79 -1
View File
@@ -2,7 +2,13 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.models import HomePantry, Ingredient, FamilyProfile 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 app.security import require_session
from uuid import UUID from uuid import UUID
from typing import List from typing import List
@@ -55,6 +61,78 @@ def add_pantry_item(item: HomePantryCreate, db: Session = Depends(get_db)):
return db_item 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)]) @router.delete("/{item_id}", dependencies=[Depends(require_session)])
def remove_pantry_item(item_id: UUID, db: Session = Depends(get_db)): def remove_pantry_item(item_id: UUID, db: Session = Depends(get_db)):
item = db.query(HomePantry).filter(HomePantry.id == item_id).first() item = db.query(HomePantry).filter(HomePantry.id == item_id).first()
+58
View File
@@ -284,6 +284,64 @@ class HomePantryCreate(HomePantryBase):
pass 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): class FeedbackBase(BaseModel):
rating: Optional[int] = Field(None, ge=1, le=5) rating: Optional[int] = Field(None, ge=1, le=5)
never_suggest: bool = False never_suggest: bool = False
+4
View File
@@ -50,6 +50,8 @@ export const mealPlannerApi = {
deleteItem: (itemId: string) => api.delete(`/meals/items/${itemId}`), deleteItem: (itemId: string) => api.delete(`/meals/items/${itemId}`),
generateItem: (mealPlanId: string, dayOfWeek: number, mealType: string) => generateItem: (mealPlanId: string, dayOfWeek: number, mealType: string) =>
api.post(`/meals/${mealPlanId}/generate-item`, null, { params: { day_of_week: dayOfWeek, meal_type: mealType } }), 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: { pantry: {
@@ -57,6 +59,8 @@ export const mealPlannerApi = {
add: (data: any) => api.post('/pantry', data), add: (data: any) => api.post('/pantry', data),
update: (id: string, data: any) => api.put(`/pantry/${id}`, data), update: (id: string, data: any) => api.put(`/pantry/${id}`, data),
remove: (id: string) => api.delete(`/pantry/${id}`), remove: (id: string) => api.delete(`/pantry/${id}`),
addBulk: (items: Array<{ ingredient_id: string; quantity?: number; unit?: string }>) =>
api.post('/pantry/bulk', { items }),
}, },
shoppingList: { shoppingList: {
+71
View File
@@ -8,6 +8,7 @@ import {
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { showToast, showApiError } from '../lib/toast' import { showToast, showApiError } from '../lib/toast'
import { isoMonday, parseIsoDate, shiftIsoDate, formatIsoDate } from '../lib/utils' import { isoMonday, parseIsoDate, shiftIsoDate, formatIsoDate } from '../lib/utils'
import { ChevronDown } from 'lucide-react'
import { import {
DragDropContext, DragDropContext,
Droppable, Droppable,
@@ -317,6 +318,36 @@ export default function Dashboard() {
const navigateWeek = (next: string) => { const navigateWeek = (next: string) => {
setSearchParams(next === isoMonday() ? {} : { week: next }, { replace: true }) 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<MealPlan | null>({ const { data: mealPlan, isLoading } = useQuery<MealPlan | null>({
queryKey: ['mealPlan', weekStart], queryKey: ['mealPlan', weekStart],
queryFn: () => mealPlannerApi.meals.getPlanned(weekStart).then(r => r.data), queryFn: () => mealPlannerApi.meals.getPlanned(weekStart).then(r => r.data),
@@ -470,6 +501,46 @@ export default function Dashboard() {
<ChevronRight className="w-4 h-4" /> <ChevronRight className="w-4 h-4" />
</button> </button>
</div> </div>
<div className="relative">
<button
onClick={() => setPlanMenuOpen(o => !o)}
disabled={planningWeek}
aria-haspopup="menu"
aria-expanded={planMenuOpen}
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium rounded-lg bg-primary-600 text-white hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{planningWeek ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Sparkles className="w-4 h-4" />
)}
{planningWeek ? 'Planning' : 'Plan the week'}
{!planningWeek && <ChevronDown className="w-3 h-3" />}
</button>
{planMenuOpen && (
<div
role="menu"
className="absolute right-0 top-full mt-1 w-56 bg-white border border-surface-200 rounded-lg shadow-lg z-20 py-1 animate-fade-in"
>
<button
role="menuitem"
onClick={() => handlePlanWeek(['dinner'])}
className="w-full text-left px-3 py-2 text-sm text-surface-700 hover:bg-surface-50 focus:outline-none focus:bg-surface-50"
>
Dinners only
<span className="block text-xs text-surface-500">Fill every empty dinner slot this week</span>
</button>
<button
role="menuitem"
onClick={() => handlePlanWeek(['breakfast', 'lunch', 'dinner'])}
className="w-full text-left px-3 py-2 text-sm text-surface-700 hover:bg-surface-50 focus:outline-none focus:bg-surface-50"
>
All meals
<span className="block text-xs text-surface-500">Fill every empty slot (breakfast, lunch, dinner) this week</span>
</button>
</div>
)}
</div>
<Badge <Badge
variant={statusVariant} variant={statusVariant}
aria-label={`Plan status: ${mealPlan.status.replace(/_/g, ' ')}`} aria-label={`Plan status: ${mealPlan.status.replace(/_/g, ' ')}`}
+62 -1
View File
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useSearchParams } from 'react-router-dom' import { useSearchParams } from 'react-router-dom'
import { Printer, ShoppingCart, Package, Tag, Receipt, RotateCcw, ChevronLeft, ChevronRight } from 'lucide-react' import { Printer, ShoppingCart, Package, Tag, Receipt, RotateCcw, ChevronLeft, ChevronRight, PackagePlus } from 'lucide-react'
import { mealPlannerApi } from '../api' import { mealPlannerApi } from '../api'
import type { ShoppingList } from '../types' import type { ShoppingList } from '../types'
import { Button } from '../components/ui/Button' import { Button } from '../components/ui/Button'
@@ -10,6 +10,7 @@ import { Card, CardBody } from '../components/ui/Card'
import { Skeleton, SkeletonText } from '../components/ui/Skeleton' import { Skeleton, SkeletonText } from '../components/ui/Skeleton'
import { EmptyState } from '../components/ui/EmptyState' import { EmptyState } from '../components/ui/EmptyState'
import { isoMonday, parseIsoDate, shiftIsoDate, formatIsoDate } from '../lib/utils' import { isoMonday, parseIsoDate, shiftIsoDate, formatIsoDate } from '../lib/utils'
import { showToast, showApiError } from '../lib/toast'
const AISLE_LABEL: Record<string, string> = { const AISLE_LABEL: Record<string, string> = {
produce: 'Produce', produce: 'Produce',
@@ -127,6 +128,55 @@ export default function ShoppingListPage() {
const clearAll = () => setChecked(new Set()) 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 = const progress =
shoppingList && shoppingList.items.length > 0 shoppingList && shoppingList.items.length > 0
? Math.round( ? Math.round(
@@ -234,6 +284,17 @@ export default function ShoppingListPage() {
{progress > 0 && ( {progress > 0 && (
<span className="text-sm text-surface-500">{progress}% complete</span> <span className="text-sm text-surface-500">{progress}% complete</span>
)} )}
{checked.size > 0 && (
<Button
variant="primary"
size="sm"
icon={<PackagePlus className="w-4 h-4" />}
onClick={addCheckedToPantry}
disabled={addingToPantry}
>
{addingToPantry ? 'Adding…' : `Add ${checked.size} to pantry`}
</Button>
)}
{checked.size > 0 && ( {checked.size > 0 && (
<Button <Button
variant="ghost" variant="ghost"