Public Access
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:
@@ -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: {
|
||||
|
||||
@@ -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, ' ')}`}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user