From 301e98433609f479f95a719beccda0d0f0cac92d Mon Sep 17 00:00:00 2001 From: Peter Woolery Date: Thu, 14 May 2026 15:49:43 -0700 Subject: [PATCH] feat: drag-and-drop meal scheduling + 7-day grid --- backend/app/api/meals.py | 43 +++++- frontend/package-lock.json | 96 +++++++++++++- frontend/package.json | 1 + frontend/src/api/index.ts | 1 + frontend/src/pages/Dashboard.tsx | 216 ++++++++++++++++++++++++------- 5 files changed, 310 insertions(+), 47 deletions(-) diff --git a/backend/app/api/meals.py b/backend/app/api/meals.py index 8a5cb60..9f4e819 100644 --- a/backend/app/api/meals.py +++ b/backend/app/api/meals.py @@ -319,4 +319,45 @@ def swap_meal_item(item_id: UUID, new_recipe_id: UUID, db: Session = Depends(get item.denial_details = None db.commit() - return {"message": "Meal swapped", "item": item} \ No newline at end of file + 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} \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index db3aad3..b947010 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "mealplanner-frontend", "version": "0.1.0", "dependencies": { + "@hello-pangea/dnd": "^18.0.1", "@tanstack/react-query": "^5.17.0", "axios": "^1.6.5", "clsx": "^2.1.1", @@ -278,6 +279,15 @@ "@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": { "version": "7.28.6", "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_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": { "version": "0.13.0", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", @@ -1354,14 +1381,14 @@ "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/react": { "version": "18.3.28", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -1378,6 +1405,12 @@ "@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": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", @@ -1841,6 +1874,15 @@ "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": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -3445,6 +3487,12 @@ ], "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": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -3487,6 +3535,29 @@ "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": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -3552,6 +3623,12 @@ "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": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -3886,6 +3963,12 @@ "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": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -4041,6 +4124,15 @@ "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": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index eee92e7..4ed0836 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,6 +10,7 @@ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0" }, "dependencies": { + "@hello-pangea/dnd": "^18.0.1", "@tanstack/react-query": "^5.17.0", "axios": "^1.6.5", "clsx": "^2.1.1", diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 871b7c4..13cee86 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -42,6 +42,7 @@ export const mealPlannerApi = { 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), 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: { diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 5577d65..c2b1823 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -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 ( - -
+
+ {/* drag handle */} +
+ +
+ {item.recipe?.image_url ? ( {item.recipe.name} ) : ( -
- +
+
)}
-

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

-

+ +

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

+ +

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

-
- +
+ {item.approval_status} {item.estimated_cost && ( - + ${item.estimated_cost.toFixed(2)} )}
- +
) } -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 ( + + {(provided: DroppableProvided, snapshot: DroppableStateSnapshot) => ( +
+ {item ? ( + + {(dragProvided: DraggableProvided, dragSnapshot: DraggableStateSnapshot) => ( +
+ +
+ )} +
+ ) : ( + /* empty slot placeholder — invisible but droppable */ +
+ Drop here +
+ )} + {provided.placeholder} +
+ )} +
+ ) +} + +/* ------------------------------------------------------------------ */ +/* DayColumn */ +/* ------------------------------------------------------------------ */ +function DayColumn({ + dayIndex, + items, +}: { + dayIndex: number + items: MealPlanItem[] +}) { const today = new Date().getDay() const isToday = today === (dayIndex + 1) % 7 return ( -
-
+
+
- + {FULL_DAY_NAMES[dayIndex]} - {isToday && Today} + {isToday && Today}
- {DAY_NAMES[dayIndex]} + {DAY_NAMES[dayIndex]}
-
+
{MEAL_TYPES.map(mealType => { const item = items.find(i => i.meal_type === mealType) return (
- + {mealType} - {item ? ( - - ) : ( -
- -
- )} +
) })} @@ -103,6 +199,9 @@ function DayColumn({ dayIndex, items }: { dayIndex: number; items: MealPlanItem[ ) } +/* ------------------------------------------------------------------ */ +/* Skeleton */ +/* ------------------------------------------------------------------ */ function DashboardSkeleton() { return (
@@ -113,10 +212,10 @@ function DashboardSkeleton() {
-
+
{Array.from({ length: 7 }).map((_, i) => ( -
- +
+ @@ -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({ queryKey: ['mealPlan'], queryFn: () => mealPlannerApi.meals.getPlanned().then(r => r.data), }) - if (isLoading) { - return + 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 + if (!mealPlan) { return (
@@ -228,17 +350,23 @@ export default function Dashboard() {
- {/* Weekly Grid */} + {/* Weekly Grid — all 7 days visible */}

Weekly Overview

-
- {itemsByDay.map((items, index) => ( - - ))} -
+ +
+ {itemsByDay.map((items, index) => ( + + ))} +
+