From 1aa1354a1eb338c0d8ad68bfec9582f54618dbf0 Mon Sep 17 00:00:00 2001 From: seanderoo Date: Wed, 8 Jul 2026 11:59:27 +0200 Subject: [PATCH] add tap-to-plan functionality and enhance mobile responsiveness in MealPlanner --- frontend/src/components/RecipeDetailModal.tsx | 2 +- frontend/src/components/modals.tsx | 4 +- frontend/src/pages/MealPlanner.tsx | 169 ++++++++++++++---- 3 files changed, 141 insertions(+), 34 deletions(-) diff --git a/frontend/src/components/RecipeDetailModal.tsx b/frontend/src/components/RecipeDetailModal.tsx index e6d8b9d..c6488f7 100644 --- a/frontend/src/components/RecipeDetailModal.tsx +++ b/frontend/src/components/RecipeDetailModal.tsx @@ -114,7 +114,7 @@ export default function RecipeDetailModal({ recipe, onClose }: { recipe: Recipe; className={`shrink-0 rounded p-0.5 transition ${ skipped ? "text-slate-400 dark:text-slate-500 hover:text-slate-700 dark:hover:text-slate-200" - : "opacity-0 group-hover:opacity-100 text-slate-400 hover:text-rose-500 dark:hover:text-rose-400" + : "sm:opacity-0 sm:group-hover:opacity-100 text-slate-400 hover:text-rose-500 dark:hover:text-rose-400" }`} > {skipped ? : } diff --git a/frontend/src/components/modals.tsx b/frontend/src/components/modals.tsx index 5820fa6..9f31d2a 100644 --- a/frontend/src/components/modals.tsx +++ b/frontend/src/components/modals.tsx @@ -8,8 +8,8 @@ export function ModalShell({ children, wide = false }: { children: ReactNode; wi return (
{children} diff --git a/frontend/src/pages/MealPlanner.tsx b/frontend/src/pages/MealPlanner.tsx index 0e09018..e9379f3 100644 --- a/frontend/src/pages/MealPlanner.tsx +++ b/frontend/src/pages/MealPlanner.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo, useEffect } from "react"; +import { useState, useMemo, useEffect, useRef } from "react"; import { DndContext, DragEndEvent, @@ -153,7 +153,7 @@ function groceryText(items: string[], weekDays: Date[]): string { // ─── Draggable recipe card ──────────────────────────────────────────────────── -function DraggableRecipeCard({ recipe }: { recipe: Recipe }) { +function DraggableRecipeCard({ recipe, onTap }: { recipe: Recipe; onTap: () => void }) { const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: `recipe-${recipe.id}`, data: { recipe }, @@ -165,7 +165,8 @@ function DraggableRecipeCard({ recipe }: { recipe: Recipe }) { style={transform ? { transform: `translate3d(${transform.x}px,${transform.y}px,0)` } : undefined} {...listeners} {...attributes} - className={`rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800/70 p-3 cursor-grab active:cursor-grabbing select-none transition ${ + onClick={onTap} + className={`rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800/70 p-3 cursor-grab active:cursor-grabbing select-none touch-manipulation transition ${ isDragging ? "opacity-30" : "hover:bg-slate-50 dark:hover:bg-slate-700/70" }`} > @@ -212,20 +213,19 @@ function DroppableDayColumn({ return (
-
+

{DAY_SHORT[dayIndex(date)]}

-

+

{date.getDate()}

@@ -238,18 +238,20 @@ function DroppableDayColumn({
{meal.servings}p @@ -257,9 +259,10 @@ function DroppableDayColumn({
@@ -270,7 +273,10 @@ function DroppableDayColumn({
)} {meals.length === 0 && !isOver && ( -

Drop recipe

+

+ Nothing planned + Drop recipe +

)}
@@ -384,6 +390,74 @@ function GroceryModal({ ); } +// ─── Day picker modal (tap-to-plan, the touch alternative to drag-and-drop) ── + +function DayPickerModal({ + recipe, + weekDays, + mealPlan, + todayKey, + onPick, + onClose, +}: { + recipe: Recipe; + weekDays: Date[]; + mealPlan: MealPlan; + todayKey: string; + onPick: (dateKey: string) => void; + onClose: () => void; +}) { + return ( +
+
+
+
+

Plan meal

+

{recipe.name}

+
+ +
+
+ {weekDays.map((date) => { + const key = toDateKey(date); + const count = (mealPlan[key] ?? []).length; + const isToday = key === todayKey; + return ( + + ); + })} +
+
+
+ ); +} + // ─── Main page component ────────────────────────────────────────────────────── export default function MealPlannerPage() { @@ -402,6 +476,9 @@ export default function MealPlannerPage() { const [recipeSearch, setRecipeSearch] = useState(""); const [showGrocery, setShowGrocery] = useState(false); const [activeRecipe, setActiveRecipe] = useState(null); + const [planningRecipe, setPlanningRecipe] = useState(null); + // A drag fires a click on the source card when it ends - don't treat it as a tap + const dragHappenedRef = useRef(false); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 } }) @@ -430,18 +507,7 @@ export default function MealPlannerPage() { [mealPlan, recipes, weekDays, inventory] ); - function handleDragStart(event: DragStartEvent) { - setActiveRecipe((event.active.data.current?.recipe as Recipe) ?? null); - } - - function handleDragEnd(event: DragEndEvent) { - setActiveRecipe(null); - const { active, over } = event; - if (!over) return; - const recipe = active.data.current?.recipe as Recipe | undefined; - if (!recipe) return; - const dateKey = String(over.id); - if (!/^\d{4}-\d{2}-\d{2}$/.test(dateKey)) return; + function addMeal(dateKey: string, recipe: Recipe) { setMealPlan((prev) => ({ ...prev, [dateKey]: [ @@ -456,6 +522,28 @@ export default function MealPlannerPage() { })); } + function handleRecipeTap(recipe: Recipe) { + if (dragHappenedRef.current) return; + setPlanningRecipe(recipe); + } + + function handleDragStart(event: DragStartEvent) { + dragHappenedRef.current = true; + setActiveRecipe((event.active.data.current?.recipe as Recipe) ?? null); + } + + function handleDragEnd(event: DragEndEvent) { + setActiveRecipe(null); + setTimeout(() => { dragHappenedRef.current = false; }, 100); + const { active, over } = event; + if (!over) return; + const recipe = active.data.current?.recipe as Recipe | undefined; + if (!recipe) return; + const dateKey = String(over.id); + if (!/^\d{4}-\d{2}-\d{2}$/.test(dateKey)) return; + addMeal(dateKey, recipe); + } + function removeMeal(dateKey: string, mealId: string) { setMealPlan((prev) => ({ ...prev, @@ -523,8 +611,8 @@ export default function MealPlannerPage() { -
-
+
+
{weekDays.map((date) => { const key = toDateKey(date); return ( @@ -545,14 +633,17 @@ export default function MealPlannerPage() { {/* ── Recipe panel ── */}

Recipes

-

Drag a recipe onto a day to plan it

+

+ Tap a recipe to plan it + Drag a recipe onto a day, or tap one to pick a day +

setRecipeSearch(e.target.value)} - className="w-full rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-800 py-2 pl-9 pr-8 text-sm text-slate-900 dark:text-slate-100 placeholder:text-slate-400 dark:placeholder:text-slate-500 focus:border-brand-600 focus:outline-none" + className="w-full rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-800 py-2 pl-9 pr-8 text-base sm:text-sm text-slate-900 dark:text-slate-100 placeholder:text-slate-400 dark:placeholder:text-slate-500 focus:border-brand-600 focus:outline-none" placeholder="Search recipes..." /> {recipeSearch && ( @@ -574,7 +665,9 @@ export default function MealPlannerPage() { ) : filteredRecipes.length === 0 ? (

No recipes match your search.

) : ( - filteredRecipes.map((recipe) => ) + filteredRecipes.map((recipe) => ( + handleRecipeTap(recipe)} /> + )) )}
@@ -585,6 +678,20 @@ export default function MealPlannerPage() { {showGrocery && ( setShowGrocery(false)} /> )} + + {planningRecipe && ( + { + addMeal(dateKey, planningRecipe); + setPlanningRecipe(null); + }} + onClose={() => setPlanningRecipe(null)} + /> + )} ); }