import { useQuery, useQueryClient } from '@tanstack/react-query' import { useState } from 'react' import { Link, useSearchParams } from 'react-router-dom' import { CookingPot, CalendarDays, ShoppingCart, ChevronRight, Sparkles, Loader2, GripVertical, X } from 'lucide-react' import toast from 'react-hot-toast' import { showToast, showApiError } from '../lib/toast' import { upcomingMonday, parseIsoDate, shiftIsoDate, formatIsoDate } from '../lib/utils' import { ChevronDown } from 'lucide-react' 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' import { WeekRangeNav } from '../components/WeekRangeNav' 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, onDelete }: { item: MealPlanItem dragHandleProps?: DraggableProvidedDragHandleProps | null isDragging?: boolean onApprove?: (itemId: string) => void // Sprint 8: optional scope. When provided, the second arg is the // deny-scope ('this_week' | 'never_again'); when omitted, defaults // to 'this_week' at the handler level. onDeny?: (itemId: string, scope?: 'this_week' | 'never_again') => 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 (
{/* Keep delete visible on touch devices where hover is unavailable. */} {onDelete && ( )}
{/* drag handle */}
{item.recipe?.image_url ? ( {item.recipe.name} ) : (
)}

{item.recipe?.name || 'Unknown Recipe'}

{totalTime > 0 && `${totalTime} min · `} {item.recipe?.servings} servings

{item.approval_status} {item.estimated_cost && ( ${item.estimated_cost.toFixed(2)} )}
{/* Sprint 8: 3-button voting row. Only shown for pending items (approved/denied items are terminal). Compact on mobile. */} {onDeny && item.approval_status === 'pending' && (
{_onApprove && ( )}
)}
) } /* ------------------------------------------------------------------ */ /* 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 ( {(provided: DroppableProvided, snapshot: DroppableStateSnapshot) => (
{item ? ( {(dragProvided: DraggableProvided, dragSnapshot: DraggableStateSnapshot) => (
)}
) : (
Empty {onGenerate && ( )}
)} {provided.placeholder}
)}
) } /* ------------------------------------------------------------------ */ /* 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 (
{FULL_DAY_NAMES[dayIndex]} {isToday && Today}
{DAY_NAMES[dayIndex]}
{MEAL_TYPES.map(mealType => { const item = items.find(i => i.meal_type === mealType) return (
{mealType}
) })}
) } function DashboardSkeleton() { return (
{Array.from({ length: 7 }).map((_, i) => (
))}
) } /* ------------------------------------------------------------------ */ /* 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) { showApiError(err, 'Failed to send vote emails') } finally { setSendingVoteEmail(false) } } return ( ) } /* ------------------------------------------------------------------ */ /* Dashboard */ /* ------------------------------------------------------------------ */ export default function Dashboard() { const queryClient = useQueryClient() const [searchParams, setSearchParams] = useSearchParams() const weekParam = searchParams.get('week') const parsedWeek = weekParam ? parseIsoDate(weekParam) : null const weekStart = weekParam && parsedWeek ? weekParam : upcomingMonday() const isCurrentWeek = weekStart === upcomingMonday() const navigateWeek = (next: string) => { setSearchParams(next === upcomingMonday() ? {} : { 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({ queryKey: ['mealPlan', weekStart], queryFn: () => mealPlannerApi.meals.getPlanned(weekStart).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', weekStart] }) } catch { // Error toast fires from the global MutationCache handler. } } 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', weekStart] }) } catch { // Error toast fires from the global MutationCache handler. } } // Sprint 8: scope-aware deny. 'this_week' (default) is a soft denial // that decays in 90 days. 'never_again' writes a permanent // NeverSuggest block. The server auto-promotes 'this_week' to // permanent on the 2nd denial in the window; the response's // `promoted_to_permanent` flag drives the toast text. async function handleDeny( itemId: string, scope: 'this_week' | 'never_again' = 'this_week', ) { try { const res = await mealPlannerApi.meals.denyItem(itemId, { scope }) const promoted = res.data?.promoted_to_permanent === true if (scope === 'never_again') { toast.success('Denied — will never be suggested again') } else if (promoted) { toast.success("Denied — won't suggest again (denied twice recently)") } else { toast.success('Denied this week — will not reappear for 90 days') } queryClient.invalidateQueries({ queryKey: ['mealPlan', weekStart] }) } catch { // Error toast fires from the global MutationCache handler. } } 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', weekStart] }) showToast.undo( 'Meal deleted', async () => { try { await mealPlannerApi.meals.generateItem( mealPlan.id, item.day_of_week, item.meal_type ) queryClient.invalidateQueries({ queryKey: ['mealPlan', weekStart] }) toast.success('Slot filled with a new meal') } catch (err) { showApiError(err, 'Failed to refill slot') } } ) } catch { // Error toast fires from the global MutationCache handler. } } 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', weekStart] }) } catch { // Error toast fires from the global MutationCache handler. } } if (isLoading) return if (!mealPlan) { return (

This Week's Meal Plan

{} }} />
) } 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 (
{/* Header */}

Week of {formatIsoDate(mealPlan.week_start_date)}

{mealPlan.items.length} meals planned · {mealPlan.items.filter(i => i.approval_status === 'approved').length} approved

navigateWeek(shiftIsoDate(weekStart, -7))} onNext={() => navigateWeek(shiftIsoDate(weekStart, 7))} onJumpHome={() => navigateWeek(upcomingMonday())} />
{planMenuOpen && (
)}
{mealPlan.status.replace(/_/g, ' ')} {mealPlan.total_estimated_cost !== undefined && (
${mealPlan.total_estimated_cost.toFixed(2)} total
)}
{/* Weekly Grid — all 7 days visible */}

Weekly Overview

{itemsByDay.map((items, index) => ( ))}
{/* Quick Actions */}

Shopping List

View your aisle-organized shopping list with sale tracking.

View shopping list

Quick Actions

Manage Pantry
) }