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 || '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
)
}