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
+41
View File
@@ -320,3 +320,44 @@ def swap_meal_item(item_id: UUID, new_recipe_id: UUID, db: Session = Depends(get
db.commit() db.commit()
return {"message": "Meal swapped", "item": item} return {"message": "Meal swapped", "item": item}
@router.put("/items/{item_id}/move")
def move_meal_item(
item_id: UUID,
new_day_of_week: int,
new_meal_type: str,
db: Session = Depends(get_db),
):
"""Move a meal to a new day/type slot.
If the target slot is occupied by another item in the same meal plan,
swap the two items.
"""
item = db.query(MealPlanItem).filter(MealPlanItem.id == item_id).first()
if not item:
raise HTTPException(status_code=404, detail="Meal plan item not found")
plan_id = item.meal_plan_id
conflict = (
db.query(MealPlanItem)
.filter(
MealPlanItem.meal_plan_id == plan_id,
MealPlanItem.day_of_week == new_day_of_week,
MealPlanItem.meal_type == new_meal_type,
MealPlanItem.id != item_id,
)
.first()
)
if conflict:
# swap day/type
conflict.day_of_week = item.day_of_week
conflict.meal_type = item.meal_type
item.day_of_week = new_day_of_week
item.meal_type = new_meal_type
db.commit()
db.refresh(item)
return {"message": "Meal moved", "item": item}
+94 -2
View File
@@ -8,6 +8,7 @@
"name": "mealplanner-frontend", "name": "mealplanner-frontend",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@hello-pangea/dnd": "^18.0.1",
"@tanstack/react-query": "^5.17.0", "@tanstack/react-query": "^5.17.0",
"axios": "^1.6.5", "axios": "^1.6.5",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -278,6 +279,15 @@
"@babel/core": "^7.0.0-0" "@babel/core": "^7.0.0-0"
} }
}, },
"node_modules/@babel/runtime": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "7.28.6", "version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -780,6 +790,23 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0" "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
} }
}, },
"node_modules/@hello-pangea/dnd": {
"version": "18.0.1",
"resolved": "https://registry.npmjs.org/@hello-pangea/dnd/-/dnd-18.0.1.tgz",
"integrity": "sha512-xojVWG8s/TGrKT1fC8K2tIWeejJYTAeJuj36zM//yEm/ZrnZUSFGS15BpO+jGZT1ybWvyXmeDJwPYb4dhWlbZQ==",
"license": "Apache-2.0",
"dependencies": {
"@babel/runtime": "^7.26.7",
"css-box-model": "^1.2.1",
"raf-schd": "^4.0.3",
"react-redux": "^9.2.0",
"redux": "^5.0.1"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
}
},
"node_modules/@humanwhocodes/config-array": { "node_modules/@humanwhocodes/config-array": {
"version": "0.13.0", "version": "0.13.0",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
@@ -1354,14 +1381,14 @@
"version": "15.7.15", "version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "18.3.28", "version": "18.3.28",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/prop-types": "*", "@types/prop-types": "*",
@@ -1378,6 +1405,12 @@
"@types/react": "^18.0.0" "@types/react": "^18.0.0"
} }
}, },
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@ungap/structured-clone": { "node_modules/@ungap/structured-clone": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
@@ -1841,6 +1874,15 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/css-box-model": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz",
"integrity": "sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==",
"license": "MIT",
"dependencies": {
"tiny-invariant": "^1.0.6"
}
},
"node_modules/cssesc": { "node_modules/cssesc": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -3445,6 +3487,12 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/raf-schd": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz",
"integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==",
"license": "MIT"
},
"node_modules/react": { "node_modules/react": {
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@@ -3487,6 +3535,29 @@
"react-dom": ">=16" "react-dom": ">=16"
} }
}, },
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-refresh": { "node_modules/react-refresh": {
"version": "0.17.0", "version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -3552,6 +3623,12 @@
"node": ">=8.10.0" "node": ">=8.10.0"
} }
}, },
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.12", "version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@@ -3886,6 +3963,12 @@
"node": ">=0.8" "node": ">=0.8"
} }
}, },
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.16", "version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
@@ -4041,6 +4124,15 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util-deprecate": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+1
View File
@@ -10,6 +10,7 @@
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
}, },
"dependencies": { "dependencies": {
"@hello-pangea/dnd": "^18.0.1",
"@tanstack/react-query": "^5.17.0", "@tanstack/react-query": "^5.17.0",
"axios": "^1.6.5", "axios": "^1.6.5",
"clsx": "^2.1.1", "clsx": "^2.1.1",
+1
View File
@@ -42,6 +42,7 @@ export const mealPlannerApi = {
getVotePage: (itemId: string, token: string) => api.get(`/meals/items/${itemId}/vote/${token}`), getVotePage: (itemId: string, token: string) => api.get(`/meals/items/${itemId}/vote/${token}`),
submitVote: (itemId: string, token: string, data: any) => api.post(`/meals/items/${itemId}/vote/${token}`, data), submitVote: (itemId: string, token: string, data: any) => api.post(`/meals/items/${itemId}/vote/${token}`, data),
swapItem: (itemId: string, newRecipeId: string) => api.post(`/meals/items/${itemId}/swap?new_recipe_id=${newRecipeId}`), swapItem: (itemId: string, newRecipeId: string) => api.post(`/meals/items/${itemId}/swap?new_recipe_id=${newRecipeId}`),
moveItem: (itemId: string, newDayOfWeek: number, newMealType: string) => api.put(`/meals/items/${itemId}/move`, null, { params: { new_day_of_week: newDayOfWeek, new_meal_type: newMealType } }),
}, },
pantry: { pantry: {
+166 -38
View File
@@ -1,8 +1,22 @@
import { useQuery } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react' import { useState } from 'react'
import { Link } from 'react-router-dom' 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 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 { mealPlannerApi } from '../api'
import type { MealPlan, MealPlanItem } from '../types' import type { MealPlan, MealPlanItem } from '../types'
import { Badge } from '../components/ui/Badge' 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 FULL_DAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
const MEAL_TYPES = ['breakfast', 'lunch', 'dinner'] as const 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 ?? 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)
@@ -25,76 +46,151 @@ function MealCard({ item }: { item: MealPlanItem }) {
'neutral' 'neutral'
return ( return (
<Link <div
to={`/meals/${item.id}`} className={`
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" 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 ? ( {item.recipe?.image_url ? (
<img <img
src={item.recipe.image_url} src={item.recipe.image_url}
alt={item.recipe.name} 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"> <div className="w-14 h-14 rounded-lg bg-surface-100 flex items-center justify-center flex-shrink-0">
<CookingPot className="w-8 h-8 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">
<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'}
</h4> </h4>
<p className="text-xs text-surface-500 mt-0.5"> </Link>
<p className="text-[11px] text-surface-500 mt-0.5">
{totalTime > 0 && `${totalTime} min · `} {totalTime > 0 && `${totalTime} min · `}
{item.recipe?.servings} servings {item.recipe?.servings} servings
</p> </p>
<div className="flex items-center gap-2 mt-2"> <div className="flex items-center gap-1.5 mt-1">
<Badge variant={statusVariant}> <Badge variant={statusVariant} className="text-[10px] px-1.5 py-0.5">
{item.approval_status} {item.approval_status}
</Badge> </Badge>
{item.estimated_cost && ( {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)} ${item.estimated_cost.toFixed(2)}
</span> </span>
)} )}
</div> </div>
</div> </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 today = new Date().getDay()
const isToday = today === (dayIndex + 1) % 7 const isToday = today === (dayIndex + 1) % 7
return ( return (
<div className="flex-shrink-0 w-[260px]"> <div className="flex flex-col gap-1">
<div className={`px-3 py-2 rounded-lg mb-2 ${isToday ? 'bg-primary-50 border border-primary-200' : 'bg-surface-100'}`}> <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"> <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]} {FULL_DAY_NAMES[dayIndex]}
</span> </span>
{isToday && <Badge variant="primary">Today</Badge>} {isToday && <Badge variant="primary" className="text-[10px]">Today</Badge>}
</div> </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>
<div className="space-y-2"> <div className="space-y-1">
{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}> <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} {mealType}
</span> </span>
{item ? ( <MealSlot
<MealCard item={item} /> dayIndex={dayIndex}
) : ( mealType={mealType}
<div className="h-20 rounded-xl border-2 border-dashed border-surface-200 flex items-center justify-center"> item={item}
<span className="text-xs text-surface-400"></span> />
</div>
)}
</div> </div>
) )
})} })}
@@ -103,6 +199,9 @@ function DayColumn({ dayIndex, items }: { dayIndex: number; items: MealPlanItem[
) )
} }
/* ------------------------------------------------------------------ */
/* Skeleton */
/* ------------------------------------------------------------------ */
function DashboardSkeleton() { function DashboardSkeleton() {
return ( return (
<div className="space-y-6 animate-fade-in"> <div className="space-y-6 animate-fade-in">
@@ -113,10 +212,10 @@ function DashboardSkeleton() {
<Skeleton className="h-7 w-20" /> <Skeleton className="h-7 w-20" />
</div> </div>
</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) => ( {Array.from({ length: 7 }).map((_, i) => (
<div key={i} className="flex-shrink-0 w-[260px] space-y-2"> <div key={i} className="space-y-2">
<Skeleton className="h-10 w-full" /> <Skeleton className="h-8 w-full" />
<SkeletonCard /> <SkeletonCard />
<SkeletonCard /> <SkeletonCard />
<SkeletonCard /> <SkeletonCard />
@@ -131,6 +230,9 @@ function DashboardSkeleton() {
) )
} }
/* ------------------------------------------------------------------ */
/* VoteEmailButton */
/* ------------------------------------------------------------------ */
function VoteEmailButton() { function VoteEmailButton() {
const [sendingVoteEmail, setSendingVoteEmail] = useState(false) const [sendingVoteEmail, setSendingVoteEmail] = useState(false)
@@ -159,15 +261,35 @@ function VoteEmailButton() {
) )
} }
/* ------------------------------------------------------------------ */
/* Dashboard */
/* ------------------------------------------------------------------ */
export default function Dashboard() { export default function Dashboard() {
const queryClient = useQueryClient()
const { data: mealPlan, isLoading } = useQuery<MealPlan | null>({ const { data: mealPlan, isLoading } = useQuery<MealPlan | null>({
queryKey: ['mealPlan'], queryKey: ['mealPlan'],
queryFn: () => mealPlannerApi.meals.getPlanned().then(r => r.data), queryFn: () => mealPlannerApi.meals.getPlanned().then(r => r.data),
}) })
if (isLoading) { async function handleDrop(itemId: string, newDay: number, newType: string) {
return <DashboardSkeleton /> 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) { if (!mealPlan) {
return ( return (
@@ -228,17 +350,23 @@ export default function Dashboard() {
</div> </div>
</div> </div>
{/* Weekly Grid */} {/* Weekly Grid — all 7 days visible */}
<Card> <Card>
<CardHeader> <CardHeader>
<h2 className="text-lg font-semibold text-surface-900">Weekly Overview</h2> <h2 className="text-lg font-semibold text-surface-900">Weekly Overview</h2>
</CardHeader> </CardHeader>
<CardBody> <CardBody>
<div className="flex gap-4 overflow-x-auto pb-4 scrollbar-hide"> <DragDropContext onDragEnd={onDragEnd}>
<div className="grid grid-cols-7 gap-2">
{itemsByDay.map((items, index) => ( {itemsByDay.map((items, index) => (
<DayColumn key={index} dayIndex={index} items={items} /> <DayColumn
key={index}
dayIndex={index}
items={items}
/>
))} ))}
</div> </div>
</DragDropContext>
</CardBody> </CardBody>
</Card> </Card>