feat: drag-and-drop meal scheduling + 7-day grid

This commit is contained in:
2026-05-14 15:49:43 -07:00
parent c21741dd56
commit 301e984336
5 changed files with 310 additions and 47 deletions
+172 -44
View File
@@ -1,8 +1,22 @@
import { useQuery } from '@tanstack/react-query'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { CookingPot, CalendarDays, ShoppingCart, ChevronRight, Sparkles, Loader2 } from 'lucide-react'
import {
CookingPot, CalendarDays, ShoppingCart, ChevronRight, Sparkles, Loader2,
GripVertical
} from 'lucide-react'
import toast from 'react-hot-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'
@@ -14,7 +28,14 @@ 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
function MealCard({ item }: { item: MealPlanItem }) {
/* ------------------------------------------------------------------ */
/* MealCard (draggable) */
/* ------------------------------------------------------------------ */
function MealCard({ item, dragHandleProps, isDragging }: {
item: MealPlanItem
dragHandleProps?: DraggableProvidedDragHandleProps | null
isDragging?: boolean
}) {
const totalTime = item.recipe?.total_time_minutes ??
(item.recipe?.prep_time_minutes || 0) + (item.recipe?.cook_time_minutes || 0)
@@ -25,76 +46,151 @@ function MealCard({ item }: { item: MealPlanItem }) {
'neutral'
return (
<Link
to={`/meals/${item.id}`}
className="group block bg-surface-0 rounded-xl border border-surface-200 overflow-hidden hover:shadow-md hover:border-primary-200 transition-all duration-200"
<div
className={`
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'}
`}
>
<div className="flex gap-3 p-3">
<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-20 h-20 object-cover rounded-lg flex-shrink-0"
className="w-14 h-14 object-cover rounded-lg flex-shrink-0"
/>
) : (
<div className="w-20 h-20 rounded-lg bg-surface-100 flex items-center justify-center flex-shrink-0">
<CookingPot className="w-8 h-8 text-surface-400" />
<div className="w-14 h-14 rounded-lg bg-surface-100 flex items-center justify-center flex-shrink-0">
<CookingPot className="w-6 h-6 text-surface-400" />
</div>
)}
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-sm text-surface-900 truncate group-hover:text-primary-700 transition-colors">
{item.recipe?.name || 'Unknown Recipe'}
</h4>
<p className="text-xs text-surface-500 mt-0.5">
<Link to={`/meals/${item.id}`} className="block">
<h4 className="font-semibold text-sm text-surface-900 truncate 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-2 mt-2">
<Badge variant={statusVariant}>
<div className="flex items-center gap-1.5 mt-1">
<Badge variant={statusVariant} className="text-[10px] px-1.5 py-0.5">
{item.approval_status}
</Badge>
{item.estimated_cost && (
<span className="text-xs font-medium text-surface-600">
<span className="text-[11px] font-medium text-surface-600">
${item.estimated_cost.toFixed(2)}
</span>
)}
</div>
</div>
</div>
</Link>
</div>
)
}
function DayColumn({ dayIndex, items }: { dayIndex: number; items: MealPlanItem[] }) {
/* ------------------------------------------------------------------ */
/* MealSlot — droppable area for one day + meal_type */
/* ------------------------------------------------------------------ */
function MealSlot({
dayIndex,
mealType,
item,
}: {
dayIndex: number
mealType: string
item?: MealPlanItem
}) {
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-[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}
/>
</div>
)}
</Draggable>
) : (
/* empty slot placeholder — invisible but droppable */
<div className="h-16 rounded-xl border-2 border-dashed border-surface-200 flex items-center justify-center">
<span className="text-xs text-surface-300">Drop here</span>
</div>
)}
{provided.placeholder}
</div>
)}
</Droppable>
)
}
/* ------------------------------------------------------------------ */
/* DayColumn */
/* ------------------------------------------------------------------ */
function DayColumn({
dayIndex,
items,
}: {
dayIndex: number
items: MealPlanItem[]
}) {
const today = new Date().getDay()
const isToday = today === (dayIndex + 1) % 7
return (
<div className="flex-shrink-0 w-[260px]">
<div className={`px-3 py-2 rounded-lg mb-2 ${isToday ? 'bg-primary-50 border border-primary-200' : 'bg-surface-100'}`}>
<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-sm font-semibold ${isToday ? 'text-primary-700' : 'text-surface-700'}`}>
<span className={`text-xs font-semibold ${isToday ? 'text-primary-700' : 'text-surface-700'}`}>
{FULL_DAY_NAMES[dayIndex]}
</span>
{isToday && <Badge variant="primary">Today</Badge>}
{isToday && <Badge variant="primary" className="text-[10px]">Today</Badge>}
</div>
<span className="text-xs text-surface-500">{DAY_NAMES[dayIndex]}</span>
<span className="text-[10px] text-surface-500">{DAY_NAMES[dayIndex]}</span>
</div>
<div className="space-y-2">
<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-[10px] font-medium text-surface-400 uppercase tracking-wider px-1">
<span className="text-[9px] font-medium text-surface-400 uppercase tracking-wider px-1 block">
{mealType}
</span>
{item ? (
<MealCard item={item} />
) : (
<div className="h-20 rounded-xl border-2 border-dashed border-surface-200 flex items-center justify-center">
<span className="text-xs text-surface-400"></span>
</div>
)}
<MealSlot
dayIndex={dayIndex}
mealType={mealType}
item={item}
/>
</div>
)
})}
@@ -103,6 +199,9 @@ function DayColumn({ dayIndex, items }: { dayIndex: number; items: MealPlanItem[
)
}
/* ------------------------------------------------------------------ */
/* Skeleton */
/* ------------------------------------------------------------------ */
function DashboardSkeleton() {
return (
<div className="space-y-6 animate-fade-in">
@@ -113,10 +212,10 @@ function DashboardSkeleton() {
<Skeleton className="h-7 w-20" />
</div>
</div>
<div className="flex gap-4 overflow-x-auto pb-4">
<div className="grid grid-cols-7 gap-2">
{Array.from({ length: 7 }).map((_, i) => (
<div key={i} className="flex-shrink-0 w-[260px] space-y-2">
<Skeleton className="h-10 w-full" />
<div key={i} className="space-y-2">
<Skeleton className="h-8 w-full" />
<SkeletonCard />
<SkeletonCard />
<SkeletonCard />
@@ -131,6 +230,9 @@ function DashboardSkeleton() {
)
}
/* ------------------------------------------------------------------ */
/* VoteEmailButton */
/* ------------------------------------------------------------------ */
function VoteEmailButton() {
const [sendingVoteEmail, setSendingVoteEmail] = useState(false)
@@ -159,16 +261,36 @@ function VoteEmailButton() {
)
}
/* ------------------------------------------------------------------ */
/* 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),
})
if (isLoading) {
return <DashboardSkeleton />
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), typeStr)
}
if (isLoading) return <DashboardSkeleton />
if (!mealPlan) {
return (
<div className="space-y-6">
@@ -228,17 +350,23 @@ export default function Dashboard() {
</div>
</div>
{/* Weekly Grid */}
{/* Weekly Grid — all 7 days visible */}
<Card>
<CardHeader>
<h2 className="text-lg font-semibold text-surface-900">Weekly Overview</h2>
</CardHeader>
<CardBody>
<div className="flex gap-4 overflow-x-auto pb-4 scrollbar-hide">
{itemsByDay.map((items, index) => (
<DayColumn key={index} dayIndex={index} items={items} />
))}
</div>
<DragDropContext onDragEnd={onDragEnd}>
<div className="grid grid-cols-7 gap-2">
{itemsByDay.map((items, index) => (
<DayColumn
key={index}
dayIndex={index}
items={items}
/>
))}
</div>
</DragDropContext>
</CardBody>
</Card>