feat(ui): close 3 P2 audit findings + a11y sweep (Sprint 3)

- lib/toast.tsx (renamed from .ts for JSX): new showToast.undo(message,
  onUndo, ms=5000) helper. Inline 'Undo' button dismisses the toast and
  fires onUndo. Note: react-hot-toast 2.6 lacks onClose/onDismiss, so
  expiry is silent — same effective behavior as confirm() declined.

- Dashboard.handleDelete: captures the full MealPlanItem before the
  DELETE so Undo can re-fire meals.generateItem(planId, dayOfWeek,
  mealType) and refill the slot (recipe may differ — see plan R4).

- Pantry.handleRemove: fully reversible — Undo re-fires pantry.add with
  the original ingredient_id, quantity, and unit. New removeId state
  scopes the spinner to the clicked row.

- Both confirm() call sites removed.

- App.tsx Navigation: whitespace-nowrap + px-2 sm:px-3 so all 4 links fit
  on one line down to 360 px. aria-current='page' on the active link.
  <nav aria-label='Primary'>, <main id='main-content'>.

- components/ui/Badge: optional icon and aria-label props. Dashboard
  approval-status Badge passes aria-label='Approval status: approved'
  (or the current value) so screen readers don't rely on color alone.

- ErrorBoundary already mounted at App.tsx:42 — verified, no code change.

- Review/sprint3-verification.md (new) + Review/ui-nielsen-audit.md and
  fix-ui-audit.md updated with Sprint 3 status and deploy steps.

Build: npm run build (tsc + vite) green. tsc 0 errors.
This commit is contained in:
2026-06-03 18:09:35 -07:00
parent f5fb7558c4
commit e90a9d6683
8 changed files with 199 additions and 39 deletions
+25 -3
View File
@@ -6,6 +6,7 @@ import {
GripVertical, X
} from 'lucide-react'
import toast from 'react-hot-toast'
import { showToast } from '../lib/toast'
import {
DragDropContext,
Droppable,
@@ -93,7 +94,11 @@ function MealCard({ item, dragHandleProps, isDragging, onApprove: _onApprove, on
{item.recipe?.servings} servings
</p>
<div className="flex items-center gap-1.5 mt-1">
<Badge variant={statusVariant} className="text-[10px] px-1.5 py-0.5">
<Badge
variant={statusVariant}
className="text-[10px] px-1.5 py-0.5"
aria-label={`Approval status: ${item.approval_status}`}
>
{item.approval_status}
</Badge>
{item.estimated_cost && (
@@ -345,11 +350,28 @@ export default function Dashboard() {
}
async function handleDelete(itemId: string) {
if (!confirm('Delete this meal from the plan?')) return
if (!mealPlan) return
const item = mealPlan.items.find(i => i.id === itemId)
if (!item) return
try {
await mealPlannerApi.meals.deleteItem(itemId)
toast.success('Meal deleted')
queryClient.invalidateQueries({ queryKey: ['mealPlan'] })
showToast.undo(
'Meal deleted',
async () => {
try {
await mealPlannerApi.meals.generateItem(
mealPlan.id,
item.day_of_week,
item.meal_type
)
queryClient.invalidateQueries({ queryKey: ['mealPlan'] })
toast.success('Slot filled with a new meal')
} catch (err: any) {
toast.error(err?.response?.data?.detail || 'Failed to refill slot')
}
}
)
} catch (err: any) {
toast.error(err?.response?.data?.detail || 'Failed to delete meal')
}
+37 -6
View File
@@ -19,6 +19,7 @@ const AISLE_OPTIONS = [
export default function Pantry() {
const queryClient = useQueryClient()
const [showAddForm, setShowAddForm] = useState(false)
const [removeId, setRemoveId] = useState<string | null>(null)
const [searchQuery, setSearchQuery] = useState('')
/* ingredient name typed by user */
@@ -71,12 +72,46 @@ export default function Pantry() {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['pantry'] })
showToast.success('Item removed')
setRemoveId(null)
},
onError: () => {
showToast.error('Failed to remove item')
setRemoveId(null)
},
})
async function handleRemove(item: HomePantryItem) {
if (!item.ingredient_id) {
showToast.error('Cannot remove: missing ingredient link')
return
}
setRemoveId(item.id)
try {
await mealPlannerApi.pantry.remove(item.id)
queryClient.invalidateQueries({ queryKey: ['pantry'] })
showToast.undo(
'Item removed',
async () => {
try {
await mealPlannerApi.pantry.add({
ingredient_id: item.ingredient_id!,
quantity: item.quantity,
unit: item.unit,
})
queryClient.invalidateQueries({ queryKey: ['pantry'] })
showToast.success('Item restored')
} catch {
showToast.error('Failed to restore item')
}
}
)
} catch {
showToast.error('Failed to remove item')
} finally {
setRemoveId(null)
}
}
async function handleAdd() {
const name = ingredientName.trim()
if (!name) {
@@ -313,12 +348,8 @@ export default function Pantry() {
variant="ghost"
size="sm"
icon={<Trash2 className="w-4 h-4" />}
onClick={() => {
if (confirm(`Remove ${item.ingredient?.name || 'this item'} from pantry?`)) {
removeMutation.mutate(item.id)
}
}}
loading={removeMutation.isPending}
onClick={() => handleRemove(item)}
loading={removeMutation.isPending && removeId === item.id}
disabled={removeMutation.isPending}
>
Remove