fix(ui): close 5 P0 audit findings (ingredients, cost, routing, mobile slots)

Sprint 1 of the UI/UX audit (Review/ui-nielsen-audit.md).

- RecipeDetail: drop .trim() on ingredient line so unit and name no longer fuse
  ('2 canBlack Beans' -> '2 can Black Beans').
- MealDetail: align ingredient field name to backend ('qty' not 'quantity'),
  add 'ingredient.name' fallback for the missing nested name from API.
- MealDetail: '$N/A per serving' -> '$X.XX' or 'No estimate'.
- App: add /recommended alias to /recipes/recommended, plus a catch-all
  NotFound page so unrecognised URLs no longer render blank.
- Dashboard: remove 'hidden md:*' on empty meal slots so mobile users can
  tap Generate. Bump empty-slot button to 44px min-height (a11y).
- EmptyState: accept an optional 'to' prop for Link-wrapped actions.
- types: extend RecipeIngredient with optional notes and nested ingredient.
This commit is contained in:
2026-06-02 10:55:53 -07:00
parent c364b8b222
commit f3e4a446a3
7 changed files with 73 additions and 41 deletions
+4 -1
View File
@@ -1,4 +1,4 @@
import { BrowserRouter, Routes, Route, Link, useLocation } from 'react-router-dom' import { BrowserRouter, Routes, Route, Link, useLocation, Navigate } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ErrorBoundary } from './components/ErrorBoundary' import { ErrorBoundary } from './components/ErrorBoundary'
import Dashboard from './pages/Dashboard' import Dashboard from './pages/Dashboard'
@@ -8,6 +8,7 @@ import Recipes from './pages/Recipes'
import Recommended from './pages/Recommended' import Recommended from './pages/Recommended'
import RecipeDetail from './pages/RecipeDetail' import RecipeDetail from './pages/RecipeDetail'
import ShoppingList from './pages/ShoppingList' import ShoppingList from './pages/ShoppingList'
import NotFound from './pages/NotFound'
const queryClient = new QueryClient() const queryClient = new QueryClient()
@@ -50,9 +51,11 @@ function App() {
<Route path="/meals/:id" element={<MealDetail />} /> <Route path="/meals/:id" element={<MealDetail />} />
<Route path="/recipes" element={<Recipes />} /> <Route path="/recipes" element={<Recipes />} />
<Route path="/recipes/recommended" element={<Recommended />} /> <Route path="/recipes/recommended" element={<Recommended />} />
<Route path="/recommended" element={<Navigate to="/recipes/recommended" replace />} />
<Route path="/recipes/:id" element={<RecipeDetail />} /> <Route path="/recipes/:id" element={<RecipeDetail />} />
<Route path="/pantry" element={<Pantry />} /> <Route path="/pantry" element={<Pantry />} />
<Route path="/shopping-list" element={<ShoppingList />} /> <Route path="/shopping-list" element={<ShoppingList />} />
<Route path="*" element={<NotFound />} />
</Routes> </Routes>
</main> </main>
</div> </div>
+10 -4
View File
@@ -1,4 +1,5 @@
import { LucideIcon } from 'lucide-react'; import { LucideIcon } from 'lucide-react';
import { Link } from 'react-router-dom';
import { Button } from './Button'; import { Button } from './Button';
interface EmptyStateProps { interface EmptyStateProps {
@@ -7,7 +8,8 @@ interface EmptyStateProps {
description: string; description: string;
action?: { action?: {
label: string; label: string;
onClick: () => void; onClick?: () => void;
to?: string;
}; };
} }
@@ -20,9 +22,13 @@ export function EmptyState({ icon: Icon, title, description, action }: EmptyStat
<h3 className="text-lg font-semibold text-surface-900 mb-1">{title}</h3> <h3 className="text-lg font-semibold text-surface-900 mb-1">{title}</h3>
<p className="text-sm text-surface-500 max-w-sm mb-6">{description}</p> <p className="text-sm text-surface-500 max-w-sm mb-6">{description}</p>
{action && ( {action && (
<Button onClick={action.onClick}> action.to ? (
{action.label} <Link to={action.to}>
</Button> <Button>{action.label}</Button>
</Link>
) : (
<Button onClick={action.onClick}>{action.label}</Button>
)
)} )}
</div> </div>
); );
+38 -31
View File
@@ -3,7 +3,7 @@ import { useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { import {
CookingPot, CalendarDays, ShoppingCart, ChevronRight, Sparkles, Loader2, CookingPot, CalendarDays, ShoppingCart, ChevronRight, Sparkles, Loader2,
GripVertical GripVertical, X
} from 'lucide-react' } from 'lucide-react'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { import {
@@ -31,12 +31,13 @@ const MEAL_TYPES = ['breakfast', 'lunch', 'dinner'] as const
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* MealCard (draggable) */ /* MealCard (draggable) */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
function MealCard({ item, dragHandleProps, isDragging, onApprove, onDeny }: { function MealCard({ item, dragHandleProps, isDragging, onApprove: _onApprove, onDeny: _onDeny, onDelete }: {
item: MealPlanItem item: MealPlanItem
dragHandleProps?: DraggableProvidedDragHandleProps | null dragHandleProps?: DraggableProvidedDragHandleProps | null
isDragging?: boolean isDragging?: boolean
onApprove?: (itemId: string) => void onApprove?: (itemId: string) => void
onDeny?: (itemId: string) => void onDeny?: (itemId: string) => void
onDelete?: (itemId: string) => void
}) { }) {
const totalTime = item.recipe?.total_time_minutes ?? const totalTime = item.recipe?.total_time_minutes ??
(item.recipe?.prep_time_minutes || 0) + (item.recipe?.cook_time_minutes || 0) (item.recipe?.prep_time_minutes || 0) + (item.recipe?.cook_time_minutes || 0)
@@ -48,13 +49,18 @@ function MealCard({ item, dragHandleProps, isDragging, onApprove, onDeny }: {
'neutral' 'neutral'
return ( return (
<div <div className={`relative group block bg-surface-0 rounded-xl border overflow-hidden hover:shadow-md hover:border-primary-200 transition-all duration-200 ${isDragging ? 'shadow-lg border-primary-400 ring-2 ring-primary-200' : 'border-surface-200'}`}>
className={` {/* Keep delete visible on touch devices where hover is unavailable. */}
group block bg-surface-0 rounded-xl border overflow-hidden {onDelete && (
hover:shadow-md hover:border-primary-200 transition-all duration-200 <button
${isDragging ? 'shadow-lg border-primary-400 ring-2 ring-primary-200' : 'border-surface-200'} onClick={(e) => { e.stopPropagation(); onDelete(item.id) }}
`} className="absolute top-1 right-1 z-10 rounded-full bg-danger-100 p-1 text-danger-600 shadow-sm transition-colors hover:bg-danger-200 hover:text-danger-800 focus:outline-none focus:ring-2 focus:ring-danger-400 focus:ring-offset-1"
> aria-label="Delete meal"
title="Delete meal"
>
<X className="w-4 h-4" />
</button>
)}
<div className="flex gap-2 p-2"> <div className="flex gap-2 p-2">
{/* drag handle */} {/* drag handle */}
<div <div
@@ -76,7 +82,7 @@ function MealCard({ item, dragHandleProps, isDragging, onApprove, onDeny }: {
<CookingPot className="w-6 h-6 text-surface-400" /> <CookingPot className="w-6 h-6 text-surface-400" />
</div> </div>
)} )}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0 pr-7">
<Link to={`/meals/${item.id}`} className="block"> <Link to={`/meals/${item.id}`} className="block">
<h4 className="font-semibold text-sm text-surface-900 truncate group-hover:text-primary-700 transition-colors"> <h4 className="font-semibold text-sm text-surface-900 truncate group-hover:text-primary-700 transition-colors">
{item.recipe?.name || 'Unknown Recipe'} {item.recipe?.name || 'Unknown Recipe'}
@@ -96,23 +102,6 @@ function MealCard({ item, dragHandleProps, isDragging, onApprove, onDeny }: {
</span> </span>
)} )}
</div> </div>
{/* Approve / Deny buttons */}
{item.approval_status === 'pending' && onApprove && onDeny && (
<div className="flex items-center gap-1 mt-1.5">
<button
onClick={(e) => { e.stopPropagation(); onApprove(item.id) }}
className="text-[10px] px-2 py-0.5 rounded bg-success-50 text-success-700 hover:bg-success-100 font-medium transition-colors"
>
Approve
</button>
<button
onClick={(e) => { e.stopPropagation(); onDeny(item.id) }}
className="text-[10px] px-2 py-0.5 rounded bg-danger-50 text-danger-700 hover:bg-danger-100 font-medium transition-colors"
>
Deny
</button>
</div>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -128,6 +117,7 @@ function MealCard({ item, dragHandleProps, isDragging, onApprove, onDeny }: {
item, item,
onApprove, onApprove,
onDeny, onDeny,
onDelete,
onGenerate, onGenerate,
}: { }: {
dayIndex: number dayIndex: number
@@ -135,6 +125,7 @@ function MealCard({ item, dragHandleProps, isDragging, onApprove, onDeny }: {
item?: MealPlanItem item?: MealPlanItem
onApprove?: (itemId: string) => void onApprove?: (itemId: string) => void
onDeny?: (itemId: string) => void onDeny?: (itemId: string) => void
onDelete?: (itemId: string) => void
onGenerate?: (dayIndex: number, mealType: string) => void onGenerate?: (dayIndex: number, mealType: string) => void
}) { }) {
const droppableId = `slot-${dayIndex}-${mealType}` const droppableId = `slot-${dayIndex}-${mealType}`
@@ -164,17 +155,18 @@ function MealCard({ item, dragHandleProps, isDragging, onApprove, onDeny }: {
isDragging={dragSnapshot.isDragging} isDragging={dragSnapshot.isDragging}
onApprove={onApprove} onApprove={onApprove}
onDeny={onDeny} onDeny={onDeny}
onDelete={onDelete}
/> />
</div> </div>
)} )}
</Draggable> </Draggable>
) : ( ) : (
<div className="hidden md:flex h-16 rounded-xl border-2 border-dashed border-surface-200 flex-col items-center justify-center gap-1"> <div className="flex h-16 rounded-xl border-2 border-dashed border-surface-200 flex-col items-center justify-center gap-1">
<span className="text-xs text-surface-300">Empty</span> <span className="text-xs text-surface-300">Empty</span>
{onGenerate && ( {onGenerate && (
<button <button
onClick={() => onGenerate(dayIndex, mealType)} onClick={() => onGenerate(dayIndex, mealType)}
className="text-[10px] px-2 py-0.5 rounded bg-primary-50 text-primary-700 hover:bg-primary-100 font-medium transition-colors" className="text-[10px] px-2 py-0.5 rounded bg-primary-50 text-primary-700 hover:bg-primary-100 font-medium transition-colors min-h-11"
> >
Generate Generate
</button> </button>
@@ -196,12 +188,14 @@ function MealCard({ item, dragHandleProps, isDragging, onApprove, onDeny }: {
items, items,
onApprove, onApprove,
onDeny, onDeny,
onDelete,
onGenerate, onGenerate,
}: { }: {
dayIndex: number dayIndex: number
items: MealPlanItem[] items: MealPlanItem[]
onApprove?: (itemId: string) => void onApprove?: (itemId: string) => void
onDeny?: (itemId: string) => void onDeny?: (itemId: string) => void
onDelete?: (itemId: string) => void
onGenerate?: (dayIndex: number, mealType: string) => void onGenerate?: (dayIndex: number, mealType: string) => void
}) { }) {
const today = new Date().getDay() const today = new Date().getDay()
@@ -222,7 +216,7 @@ function MealCard({ item, dragHandleProps, isDragging, onApprove, onDeny }: {
{MEAL_TYPES.map(mealType => { {MEAL_TYPES.map(mealType => {
const item = items.find(i => i.meal_type === mealType) const item = items.find(i => i.meal_type === mealType)
return ( return (
<div key={mealType} className={item ? '' : 'hidden md:block'}> <div key={mealType}>
<span className="text-[9px] font-medium text-surface-400 uppercase tracking-wider px-1 block"> <span className="text-[9px] font-medium text-surface-400 uppercase tracking-wider px-1 block">
{mealType} {mealType}
</span> </span>
@@ -232,6 +226,7 @@ function MealCard({ item, dragHandleProps, isDragging, onApprove, onDeny }: {
item={item} item={item}
onApprove={onApprove} onApprove={onApprove}
onDeny={onDeny} onDeny={onDeny}
onDelete={onDelete}
onGenerate={onGenerate} onGenerate={onGenerate}
/> />
</div> </div>
@@ -326,7 +321,7 @@ export default function Dashboard() {
const { draggableId, destination } = result const { draggableId, destination } = result
const itemId = draggableId.replace('item-', '') const itemId = draggableId.replace('item-', '')
const [, dayStr, typeStr] = destination.droppableId.split('-') const [, dayStr, typeStr] = destination.droppableId.split('-')
handleDrop(itemId, parseInt(dayStr, 10), typeStr) handleDrop(itemId, parseInt(dayStr, 10) + 1, typeStr)
} }
async function handleApprove(itemId: string) { async function handleApprove(itemId: string) {
@@ -349,6 +344,17 @@ export default function Dashboard() {
} }
} }
async function handleDelete(itemId: string) {
if (!confirm('Delete this meal from the plan?')) return
try {
await mealPlannerApi.meals.deleteItem(itemId)
toast.success('Meal deleted')
queryClient.invalidateQueries({ queryKey: ['mealPlan'] })
} catch (err: any) {
toast.error(err?.response?.data?.detail || 'Failed to delete meal')
}
}
async function handleGenerate(dayIndex: number, mealType: string) { async function handleGenerate(dayIndex: number, mealType: string) {
if (!mealPlan) return if (!mealPlan) return
try { try {
@@ -436,6 +442,7 @@ export default function Dashboard() {
items={items} items={items}
onApprove={handleApprove} onApprove={handleApprove}
onDeny={handleDeny} onDeny={handleDeny}
onDelete={handleDelete}
onGenerate={handleGenerate} onGenerate={handleGenerate}
/> />
))} ))}
+5 -4
View File
@@ -188,7 +188,9 @@ export default function MealDetail() {
</div> </div>
<div className="text-left sm:text-right flex-shrink-0"> <div className="text-left sm:text-right flex-shrink-0">
<div className="text-xl sm:text-2xl font-bold text-white"> <div className="text-xl sm:text-2xl font-bold text-white">
${item.estimated_cost?.toFixed(2) || 'N/A'} {item.estimated_cost != null
? `$${item.estimated_cost.toFixed(2)}`
: 'No estimate'}
</div> </div>
<div className="text-xs sm:text-sm text-white/70">per serving</div> <div className="text-xs sm:text-sm text-white/70">per serving</div>
</div> </div>
@@ -246,10 +248,9 @@ export default function MealDetail() {
<div className="w-2 h-2 rounded-full bg-primary-400 flex-shrink-0" /> <div className="w-2 h-2 rounded-full bg-primary-400 flex-shrink-0" />
<span className="text-sm text-surface-700"> <span className="text-sm text-surface-700">
<span className="font-medium"> <span className="font-medium">
{ing.quantity && `${ing.quantity} `} {ing.qty != null && `${ing.qty}${ing.unit ? ` ${ing.unit}` : ''} `}
{ing.unit && `${ing.unit} `}
</span> </span>
{ing.name} {ing.name || ing.ingredient?.name || 'Ingredient'}
{ing.is_optional && ( {ing.is_optional && (
<span className="text-surface-400 ml-1">(optional)</span> <span className="text-surface-400 ml-1">(optional)</span>
)} )}
+13
View File
@@ -0,0 +1,13 @@
import { Compass } from 'lucide-react';
import { EmptyState } from '../components/ui/EmptyState';
export default function NotFound() {
return (
<EmptyState
icon={Compass}
title="We can't find that page"
description="The page you were looking for doesn't exist or has been moved."
action={{ label: 'Back to dashboard', to: '/' }}
/>
);
}
+1 -1
View File
@@ -158,7 +158,7 @@ export default function RecipeDetail() {
<li key={i} className="flex items-start gap-2 text-sm text-surface-700"> <li key={i} className="flex items-start gap-2 text-sm text-surface-700">
<span className="w-2 h-2 rounded-full bg-primary-400 mt-1.5 flex-shrink-0" /> <span className="w-2 h-2 rounded-full bg-primary-400 mt-1.5 flex-shrink-0" />
<span> <span>
{ing.qty != null && `${ing.qty} ${ing.unit || ''} `.trim()} {ing.qty != null && `${ing.qty}${ing.unit ? ` ${ing.unit}` : ''} `}
{ing.name || 'Unknown ingredient'} {ing.name || 'Unknown ingredient'}
</span> </span>
</li> </li>
+2
View File
@@ -58,6 +58,8 @@ export interface RecipeIngredient {
quantity?: number quantity?: number
unit?: string unit?: string
is_optional: boolean is_optional: boolean
notes?: string | null
ingredient?: { name?: string; aisle?: string }
} }
export interface MealPlan { export interface MealPlan {