Files
Meal-Planner/frontend/src/pages/Dashboard.tsx
T
admin e90a9d6683 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.
2026-06-03 18:09:35 -07:00

525 lines
20 KiB
TypeScript

import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { Link } from 'react-router-dom'
import {
CookingPot, CalendarDays, ShoppingCart, ChevronRight, Sparkles, Loader2,
GripVertical, X
} from 'lucide-react'
import toast from 'react-hot-toast'
import { showToast } from '../lib/toast'
import {
DragDropContext,
Droppable,
Draggable,
type DropResult,
type DroppableProvided,
type DraggableProvided,
type DraggableProvidedDragHandleProps,
type DraggableStateSnapshot,
type DroppableStateSnapshot,
} from '@hello-pangea/dnd'
import { mealPlannerApi } from '../api'
import type { MealPlan, MealPlanItem } from '../types'
import { Badge } from '../components/ui/Badge'
import { Card, CardBody, CardHeader } from '../components/ui/Card'
import { SkeletonCard, Skeleton } from '../components/ui/Skeleton'
import { EmptyState } from '../components/ui/EmptyState'
const DAY_NAMES = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
const FULL_DAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
const MEAL_TYPES = ['breakfast', 'lunch', 'dinner'] as const
/* ------------------------------------------------------------------ */
/* MealCard (draggable) */
/* ------------------------------------------------------------------ */
function MealCard({ item, dragHandleProps, isDragging, onApprove: _onApprove, onDeny: _onDeny, onDelete }: {
item: MealPlanItem
dragHandleProps?: DraggableProvidedDragHandleProps | null
isDragging?: boolean
onApprove?: (itemId: string) => void
onDeny?: (itemId: string) => void
onDelete?: (itemId: string) => void
}) {
const totalTime = item.recipe?.total_time_minutes ??
(item.recipe?.prep_time_minutes || 0) + (item.recipe?.cook_time_minutes || 0)
const statusVariant =
item.approval_status === 'approved' ? 'success' :
item.approval_status === 'denied' ? 'danger' :
item.approval_status === 'swapped' ? 'warning' :
'neutral'
return (
<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'}`}>
{/* Keep delete visible on touch devices where hover is unavailable. */}
{onDelete && (
<button
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">
{/* drag handle */}
<div
{...dragHandleProps}
className="flex-shrink-0 self-center cursor-grab active:cursor-grabbing text-surface-400 hover:text-surface-600"
aria-label="Drag to move meal"
>
<GripVertical className="w-4 h-4" />
</div>
{item.recipe?.image_url ? (
<img
src={item.recipe.image_url}
alt={item.recipe.name}
className="w-10 h-10 md:w-14 md:h-14 object-cover rounded-lg flex-shrink-0"
/>
) : (
<div className="w-10 h-10 md:w-14 md:h-14 rounded-lg bg-surface-100 flex items-center justify-center flex-shrink-0">
<CookingPot className="w-5 h-5 md:w-6 md:h-6 text-surface-400" />
</div>
)}
<div className="flex-1 min-w-0 pr-7">
<Link to={`/meals/${item.id}`} className="block">
<h4 className="font-semibold text-sm text-surface-900 leading-tight line-clamp-2 group-hover:text-primary-700 transition-colors">
{item.recipe?.name || 'Unknown Recipe'}
</h4>
</Link>
<p className="text-[11px] text-surface-500 mt-0.5">
{totalTime > 0 && `${totalTime} min · `}
{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"
aria-label={`Approval status: ${item.approval_status}`}
>
{item.approval_status}
</Badge>
{item.estimated_cost && (
<span className="text-[11px] font-medium text-surface-600">
${item.estimated_cost.toFixed(2)}
</span>
)}
</div>
</div>
</div>
</div>
)
}
/* ------------------------------------------------------------------ */
/* MealSlot — droppable area for one day + meal_type */
/* ------------------------------------------------------------------ */
function MealSlot({
dayIndex,
mealType,
item,
onApprove,
onDeny,
onDelete,
onGenerate,
}: {
dayIndex: number
mealType: string
item?: MealPlanItem
onApprove?: (itemId: string) => void
onDeny?: (itemId: string) => void
onDelete?: (itemId: string) => void
onGenerate?: (dayIndex: number, mealType: string) => void
}) {
const droppableId = `slot-${dayIndex}-${mealType}`
return (
<Droppable droppableId={droppableId}>
{(provided: DroppableProvided, snapshot: DroppableStateSnapshot) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
className={`
rounded-lg p-1 min-h-[20px] md:min-h-[80px] transition-colors
${snapshot.isDraggingOver ? 'bg-primary-50 ring-2 ring-primary-300' : 'bg-transparent'}
`}
>
{item ? (
<Draggable draggableId={`item-${item.id}`} index={0}>
{(dragProvided: DraggableProvided, dragSnapshot: DraggableStateSnapshot) => (
<div
ref={dragProvided.innerRef}
{...dragProvided.draggableProps}
style={dragProvided.draggableProps.style}
>
<MealCard
item={item}
dragHandleProps={dragProvided.dragHandleProps}
isDragging={dragSnapshot.isDragging}
onApprove={onApprove}
onDeny={onDeny}
onDelete={onDelete}
/>
</div>
)}
</Draggable>
) : (
<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>
{onGenerate && (
<button
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 min-h-11"
>
Generate
</button>
)}
</div>
)}
{provided.placeholder}
</div>
)}
</Droppable>
)
}
/* ------------------------------------------------------------------ */
/* DayColumn */
/* ------------------------------------------------------------------ */
function DayColumn({
dayIndex,
items,
onApprove,
onDeny,
onDelete,
onGenerate,
}: {
dayIndex: number
items: MealPlanItem[]
onApprove?: (itemId: string) => void
onDeny?: (itemId: string) => void
onDelete?: (itemId: string) => void
onGenerate?: (dayIndex: number, mealType: string) => void
}) {
const today = new Date().getDay()
const isToday = today === (dayIndex + 1) % 7
return (
<div className="flex flex-col gap-1">
<div className={`px-2 py-1.5 rounded-lg ${isToday ? 'bg-primary-50 border border-primary-200' : 'bg-surface-100'}`}>
<div className="flex items-center justify-between">
<span className={`text-xs font-semibold ${isToday ? 'text-primary-700' : 'text-surface-700'}`}>
{FULL_DAY_NAMES[dayIndex]}
</span>
{isToday && <Badge variant="primary" className="text-[10px]">Today</Badge>}
</div>
<span className="text-[10px] text-surface-500">{DAY_NAMES[dayIndex]}</span>
</div>
<div className="space-y-1">
{MEAL_TYPES.map(mealType => {
const item = items.find(i => i.meal_type === mealType)
return (
<div key={mealType}>
<span className="text-[9px] font-medium text-surface-400 uppercase tracking-wider px-1 block">
{mealType}
</span>
<MealSlot
dayIndex={dayIndex}
mealType={mealType}
item={item}
onApprove={onApprove}
onDeny={onDeny}
onDelete={onDelete}
onGenerate={onGenerate}
/>
</div>
)
})}
</div>
</div>
)
}
function DashboardSkeleton() {
return (
<div className="space-y-6 animate-fade-in">
<div className="flex justify-between items-center">
<Skeleton className="h-8 w-48" />
<div className="flex gap-2">
<Skeleton className="h-7 w-24" />
<Skeleton className="h-7 w-20" />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-2">
{Array.from({ length: 7 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-8 w-full" />
<div className="hidden sm:block"><SkeletonCard /></div>
<div className="hidden sm:block"><SkeletonCard /></div>
<div className="hidden sm:block"><SkeletonCard /></div>
</div>
))}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<SkeletonCard />
<SkeletonCard />
</div>
</div>
)
}
/* ------------------------------------------------------------------ */
/* VoteEmailButton */
/* ------------------------------------------------------------------ */
function VoteEmailButton() {
const [sendingVoteEmail, setSendingVoteEmail] = useState(false)
async function handleSend() {
setSendingVoteEmail(true)
try {
const res = await mealPlannerApi.admin.triggerOrchestrate('email')
const status = res.data?.status || res.data?.message || 'sent'
toast.success(status)
} catch (err: any) {
toast.error(err?.response?.data?.detail || 'Failed to send vote emails')
} finally {
setSendingVoteEmail(false)
}
}
return (
<button
onClick={handleSend}
disabled={sendingVoteEmail}
className="inline-flex items-center gap-1 text-sm font-medium text-primary-600 hover:text-primary-700 transition-colors text-left disabled:opacity-50 disabled:cursor-not-allowed"
>
{sendingVoteEmail ? <Loader2 className="w-4 h-4 animate-spin" /> : <ChevronRight className="w-4 h-4" />}
{sendingVoteEmail ? 'Sending...' : 'Send Vote Email'}
</button>
)
}
/* ------------------------------------------------------------------ */
/* Dashboard */
/* ------------------------------------------------------------------ */
export default function Dashboard() {
const queryClient = useQueryClient()
const { data: mealPlan, isLoading } = useQuery<MealPlan | null>({
queryKey: ['mealPlan'],
queryFn: () => mealPlannerApi.meals.getPlanned().then(r => r.data),
})
async function handleDrop(itemId: string, newDay: number, newType: string) {
try {
await mealPlannerApi.meals.moveItem(itemId, newDay, newType)
toast.success('Meal moved')
queryClient.invalidateQueries({ queryKey: ['mealPlan'] })
} catch (err: any) {
toast.error(err?.response?.data?.detail || 'Failed to move meal')
}
}
function onDragEnd(result: DropResult) {
if (!result.destination) return
const { draggableId, destination } = result
const itemId = draggableId.replace('item-', '')
const [, dayStr, typeStr] = destination.droppableId.split('-')
handleDrop(itemId, parseInt(dayStr, 10) + 1, typeStr)
}
async function handleApprove(itemId: string) {
try {
await mealPlannerApi.meals.approveItem(itemId)
toast.success('Meal approved')
queryClient.invalidateQueries({ queryKey: ['mealPlan'] })
} catch (err: any) {
toast.error(err?.response?.data?.detail || 'Failed to approve meal')
}
}
async function handleDeny(itemId: string) {
try {
await mealPlannerApi.meals.denyItem(itemId)
toast.success('Meal denied')
queryClient.invalidateQueries({ queryKey: ['mealPlan'] })
} catch (err: any) {
toast.error(err?.response?.data?.detail || 'Failed to deny meal')
}
}
async function handleDelete(itemId: string) {
if (!mealPlan) return
const item = mealPlan.items.find(i => i.id === itemId)
if (!item) return
try {
await mealPlannerApi.meals.deleteItem(itemId)
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')
}
}
async function handleGenerate(dayIndex: number, mealType: string) {
if (!mealPlan) return
try {
await mealPlannerApi.meals.generateItem(mealPlan.id, dayIndex + 1, mealType)
toast.success('Meal generated')
queryClient.invalidateQueries({ queryKey: ['mealPlan'] })
} catch (err: any) {
toast.error(err?.response?.data?.detail || 'Failed to generate meal')
}
}
if (isLoading) return <DashboardSkeleton />
if (!mealPlan) {
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<CalendarDays className="w-6 h-6 text-primary-600" />
<h1 className="text-2xl font-bold text-surface-900">This Week's Meal Plan</h1>
</div>
<EmptyState
icon={Sparkles}
title="No meal plan yet"
description="Generate your first weekly meal plan to get started with smart shopping lists and vote emails."
action={{ label: 'Generate Meal Plan', onClick: () => {} }}
/>
</div>
)
}
const itemsByDay = Array.from({ length: 7 }, (_, i) =>
mealPlan.items.filter(item => item.day_of_week === i + 1)
)
const statusVariant =
mealPlan.status === 'approved' ? 'success' :
mealPlan.status === 'locked' ? 'primary' :
mealPlan.status === 'pending_approval' ? 'warning' :
'neutral'
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-primary-50 flex items-center justify-center">
<CalendarDays className="w-5 h-5 text-primary-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-surface-900">
Week of {new Date(mealPlan.week_start_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
</h1>
<p className="text-sm text-surface-500">
{mealPlan.items.length} meals planned · {mealPlan.items.filter(i => i.approval_status === 'approved').length} approved
</p>
</div>
</div>
<div className="flex items-center gap-3">
<Badge variant={statusVariant}>
{mealPlan.status.replace(/_/g, ' ')}
</Badge>
{mealPlan.total_estimated_cost !== undefined && (
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-surface-100 rounded-lg">
<span className="text-sm font-semibold text-surface-900">
${mealPlan.total_estimated_cost.toFixed(2)}
</span>
<span className="text-xs text-surface-500">total</span>
</div>
)}
</div>
</div>
{/* Weekly Grid — all 7 days visible */}
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-surface-900">Weekly Overview</h2>
</CardHeader>
<CardBody>
<DragDropContext onDragEnd={onDragEnd}>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-2">
{itemsByDay.map((items, index) => (
<DayColumn
key={index}
dayIndex={index}
items={items}
onApprove={handleApprove}
onDeny={handleDeny}
onDelete={handleDelete}
onGenerate={handleGenerate}
/>
))}
</div>
</DragDropContext>
</CardBody>
</Card>
{/* Quick Actions */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card className="hover:shadow-md transition-shadow">
<CardBody>
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-success-50 flex items-center justify-center flex-shrink-0">
<ShoppingCart className="w-5 h-5 text-success-600" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-surface-900">Shopping List</h3>
<p className="text-sm text-surface-500 mt-1">
View your aisle-organized shopping list with sale tracking.
</p>
<Link
to="/shopping-list"
className="inline-flex items-center gap-1 mt-3 text-sm font-medium text-primary-600 hover:text-primary-700 transition-colors"
>
View shopping list <ChevronRight className="w-4 h-4" />
</Link>
</div>
</div>
</CardBody>
</Card>
<Card className="hover:shadow-md transition-shadow">
<CardBody>
<div className="flex items-start gap-4">
<div className="w-10 h-10 rounded-xl bg-warning-50 flex items-center justify-center flex-shrink-0">
<Sparkles className="w-5 h-5 text-warning-600" />
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-surface-900">Quick Actions</h3>
<div className="flex flex-col gap-2 mt-2">
<Link
to="/pantry"
className="inline-flex items-center gap-1 text-sm font-medium text-primary-600 hover:text-primary-700 transition-colors"
>
Manage Pantry <ChevronRight className="w-4 h-4" />
</Link>
<VoteEmailButton />
</div>
</div>
</div>
</CardBody>
</Card>
</div>
</div>
)
}