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
+4
View File
@@ -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: {
+71
View File
@@ -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<MealPlan | null>({
queryKey: ['mealPlan', weekStart],
queryFn: () => mealPlannerApi.meals.getPlanned(weekStart).then(r => r.data),
@@ -470,6 +501,46 @@ export default function Dashboard() {
<ChevronRight className="w-4 h-4" />
</button>
</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
variant={statusVariant}
aria-label={`Plan status: ${mealPlan.status.replace(/_/g, ' ')}`}
+62 -1
View File
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
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 type { ShoppingList } from '../types'
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 { EmptyState } from '../components/ui/EmptyState'
import { isoMonday, parseIsoDate, shiftIsoDate, formatIsoDate } from '../lib/utils'
import { showToast, showApiError } from '../lib/toast'
const AISLE_LABEL: Record<string, string> = {
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 && (
<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 && (
<Button
variant="ghost"