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