From aa67868b9693cb919b5b1b1a7a3b21801bf61a72 Mon Sep 17 00:00:00 2001 From: Arnaud Date: Mon, 13 Jul 2026 22:07:12 +0200 Subject: [PATCH] feat: clear a meal-plan day or the whole week in one action New bulk-delete route for the owner's own weekStart-scoped plan (mirrors recipes/bulk's DELETE), plus ?ids= support added to the existing shared-plan DELETE route (kept ?entryId= working unchanged). UI: hover-reveal trash icon per day header, "Clear week" button in the toolbar, one shared confirm dialog for both. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 5 ++ .../[weekStart]/entries/bulk/route.ts | 33 +++++++ .../shared/[mealPlanId]/entries/route.ts | 10 ++- .../web/components/meal-plan/meal-planner.tsx | 85 ++++++++++++++++++- apps/web/lib/changelog.ts | 9 +- apps/web/messages/en.json | 8 +- apps/web/messages/fr.json | 8 +- apps/web/package.json | 2 +- package.json | 2 +- 9 files changed, 151 insertions(+), 11 deletions(-) create mode 100644 apps/web/app/api/v1/meal-plans/[weekStart]/entries/bulk/route.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 05fe340..34458b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together. +## 0.17.0 — 2026-07-13 22:06 + +### Added +- **Clear a day or the whole meal-plan week** in one click instead of removing each meal individually. + ## 0.16.0 — 2026-07-13 21:59 ### Added diff --git a/apps/web/app/api/v1/meal-plans/[weekStart]/entries/bulk/route.ts b/apps/web/app/api/v1/meal-plans/[weekStart]/entries/bulk/route.ts new file mode 100644 index 0000000..9ea2852 --- /dev/null +++ b/apps/web/app/api/v1/meal-plans/[weekStart]/entries/bulk/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { db, mealPlans, mealPlanEntries, eq, and, inArray } from "@epicure/db"; +import { requireSession } from "@/lib/api-auth"; + +type Params = { params: Promise<{ weekStart: string }> }; + +const BulkDeleteSchema = z.object({ + ids: z.array(z.string()).min(1).max(100), +}); + +// Clear-a-day / clear-a-week — the meal planner has no per-entry select mode, +// so this is only ever called with either one day's entry ids or the whole +// week's, scoped to the caller's own plan. +export async function DELETE(req: NextRequest, { params }: Params) { + const { session, response } = await requireSession(); + if (response) return response; + const { weekStart } = await params; + + const plan = await db.query.mealPlans.findFirst({ + where: and(eq(mealPlans.userId, session!.user.id), eq(mealPlans.weekStart, weekStart)), + }); + if (!plan) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + const body = BulkDeleteSchema.safeParse(await req.json()); + if (!body.success) return NextResponse.json({ error: "Validation error" }, { status: 400 }); + + await db.delete(mealPlanEntries).where( + and(inArray(mealPlanEntries.id, body.data.ids), eq(mealPlanEntries.mealPlanId, plan.id)) + ); + + return NextResponse.json({ ok: true }); +} diff --git a/apps/web/app/api/v1/meal-plans/shared/[mealPlanId]/entries/route.ts b/apps/web/app/api/v1/meal-plans/shared/[mealPlanId]/entries/route.ts index 9318ba4..14aeb80 100644 --- a/apps/web/app/api/v1/meal-plans/shared/[mealPlanId]/entries/route.ts +++ b/apps/web/app/api/v1/meal-plans/shared/[mealPlanId]/entries/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; -import { db, mealPlanEntries, recipes, eq, and, or, ne } from "@epicure/db"; +import { db, mealPlanEntries, recipes, eq, and, or, ne, inArray } from "@epicure/db"; import { requireSession } from "@/lib/api-auth"; import { getMealPlanAccessById, canWriteMealPlan } from "@/lib/meal-plan-access"; import { dispatchWebhook } from "@/lib/webhooks"; @@ -70,11 +70,15 @@ export async function DELETE(req: NextRequest, { params }: Params) { if (!access) return NextResponse.json({ error: "Not found" }, { status: 404 }); if (!canWriteMealPlan(access.role)) return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + // ?entryId=x for a single delete (existing behavior), or ?ids=a,b,c for a + // "clear this day"/"clear this week" bulk delete — same access check either way. const entryId = req.nextUrl.searchParams.get("entryId"); - if (!entryId) return NextResponse.json({ error: "entryId required" }, { status: 400 }); + const idsParam = req.nextUrl.searchParams.get("ids"); + const ids = idsParam ? idsParam.split(",").filter(Boolean) : entryId ? [entryId] : []; + if (ids.length === 0) return NextResponse.json({ error: "entryId or ids required" }, { status: 400 }); await db.delete(mealPlanEntries).where( - and(eq(mealPlanEntries.id, entryId), eq(mealPlanEntries.mealPlanId, mealPlanId)) + and(inArray(mealPlanEntries.id, ids), eq(mealPlanEntries.mealPlanId, mealPlanId)) ); return new NextResponse(null, { status: 204 }); diff --git a/apps/web/components/meal-plan/meal-planner.tsx b/apps/web/components/meal-plan/meal-planner.tsx index 138d292..5f0fefc 100644 --- a/apps/web/components/meal-plan/meal-planner.tsx +++ b/apps/web/components/meal-plan/meal-planner.tsx @@ -5,6 +5,16 @@ import Link from "next/link"; import { Plus, Trash2, Sparkles, ChefHat, Check } from "lucide-react"; import { toast } from "sonner"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { FakeProgressBar } from "@/components/ui/fake-progress-bar"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; @@ -190,8 +200,11 @@ export function MealPlanner({ const [pantryMode, setPantryMode] = useState(false); const [aiDifficulty, setAiDifficulty] = useState<"" | "easy" | "medium" | "hard">(""); const [targetNutritionGoals, setTargetNutritionGoals] = useState(false); + const [clearTarget, setClearTarget] = useState<{ type: "day"; day: Day; label: string } | { type: "week" } | null>(null); + const [clearing, setClearing] = useState(false); const t = useTranslations("mealPlan"); const tRecipe = useTranslations("recipe"); + const tCommon = useTranslations("common"); async function generateWithAi() { setAiGenerating(true); @@ -292,6 +305,29 @@ export function MealPlanner({ } } + async function confirmClear() { + if (!clearTarget) return; + const ids = ( + clearTarget.type === "day" ? entries.filter((e) => e.day === clearTarget.day) : entries + ).map((e) => e.id); + if (ids.length === 0) { setClearTarget(null); return; } + + setClearing(true); + try { + const res = await fetch(`/api/v1/meal-plans/${weekStart}/entries/bulk`, { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ids }), + }); + if (!res.ok) { toast.error(t("removeFailed")); return; } + setEntries((prev) => prev.filter((e) => !ids.includes(e.id))); + toast.success(t("clearedSuccess", { count: ids.length })); + } finally { + setClearing(false); + setClearTarget(null); + } + } + async function markCooked(entry: Entry) { if (!entry.recipe || markingCookedIds.has(entry.id)) return; setMarkingCookedIds((prev) => new Set(prev).add(entry.id)); @@ -325,6 +361,12 @@ export function MealPlanner({ <> {/* AI Generate buttons */}
+ {entries.length > 0 && ( + + )} + )} + + + ); + })} @@ -541,6 +599,27 @@ export function MealPlanner({ )} + + !open && setClearTarget(null)}> + + + + {clearTarget?.type === "day" ? t("clearDayConfirmTitle", { day: clearTarget.label }) : t("clearWeekConfirmTitle")} + + {t("clearConfirmDescription")} + + + {tCommon("cancel")} + { void confirmClear(); }} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {tCommon("delete")} + + + + ); } diff --git a/apps/web/lib/changelog.ts b/apps/web/lib/changelog.ts index c429ef5..f753cfc 100644 --- a/apps/web/lib/changelog.ts +++ b/apps/web/lib/changelog.ts @@ -1,5 +1,5 @@ // Mirrors CHANGELOG.md at the repo root — update both together. -export const APP_VERSION = "0.16.0"; +export const APP_VERSION = "0.17.0"; export type ChangelogEntry = { version: string; @@ -11,6 +11,13 @@ export type ChangelogEntry = { }; export const CHANGELOG: ChangelogEntry[] = [ + { + version: "0.17.0", + date: "2026-07-13 22:06", + added: [ + "**Clear a day or the whole meal-plan week** in one click instead of removing each meal individually.", + ], + }, { version: "0.16.0", date: "2026-07-13 21:59", diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 378c17a..89f2c6e 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -829,7 +829,13 @@ "removeEntry": "Remove meal", "markCooked": "Mark as cooked", "markCookedSuccess": "Marked as cooked", - "markCookedFailed": "Failed to mark as cooked" + "markCookedFailed": "Failed to mark as cooked", + "clearDay": "Clear day", + "clearWeek": "Clear week", + "clearDayConfirmTitle": "Clear {day}?", + "clearWeekConfirmTitle": "Clear the whole week?", + "clearConfirmDescription": "This removes every planned meal shown — it doesn't delete the recipes themselves.", + "clearedSuccess": "{count, plural, one {1 meal cleared} other {{count} meals cleared}}" }, "pantry": { "title": "Pantry", diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index fb93ac6..7a6987c 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -817,7 +817,13 @@ "removeEntry": "Retirer le repas", "markCooked": "Marquer comme cuisiné", "markCookedSuccess": "Marqué comme cuisiné", - "markCookedFailed": "Échec du marquage comme cuisiné" + "markCookedFailed": "Échec du marquage comme cuisiné", + "clearDay": "Vider la journée", + "clearWeek": "Vider la semaine", + "clearDayConfirmTitle": "Vider {day} ?", + "clearWeekConfirmTitle": "Vider toute la semaine ?", + "clearConfirmDescription": "Cela retire tous les repas planifiés affichés — les recettes elles-mêmes ne sont pas supprimées.", + "clearedSuccess": "{count, plural, one {1 repas retiré} other {{count} repas retirés}}" }, "pantry": { "title": "Garde-manger", diff --git a/apps/web/package.json b/apps/web/package.json index 51e2c12..43844e6 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@epicure/web", - "version": "0.16.0", + "version": "0.17.0", "private": true, "scripts": { "dev": "next dev", diff --git a/package.json b/package.json index 6fc286b..6750883 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "epicure", - "version": "0.16.0", + "version": "0.17.0", "private": true, "scripts": { "dev": "pnpm --filter web dev",