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 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-13 22:07:12 +02:00
parent a144052e46
commit aa67868b96
9 changed files with 151 additions and 11 deletions
+5
View File
@@ -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
@@ -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 });
}
@@ -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 });
+82 -3
View File
@@ -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 */}
<div className="flex justify-end gap-2 mb-4">
{entries.length > 0 && (
<Button variant="ghost" size="sm" onClick={() => setClearTarget({ type: "week" })} className="gap-2 text-muted-foreground">
<Trash2 className="h-4 w-4" />
{t("clearWeek")}
</Button>
)}
<Button variant="outline" size="sm" onClick={() => setShowBatchModal(true)} className="gap-2">
<ChefHat className="h-4 w-4" />
{t("batchCooking")}
@@ -346,9 +388,25 @@ export function MealPlanner({
<thead>
<tr>
<th className="text-left text-xs font-medium text-muted-foreground pb-3" />
{DAYS.map(({ key, label }) => (
<th key={key} className="text-center text-sm font-semibold pb-3 px-2">{label}</th>
))}
{DAYS.map(({ key, label }) => {
const dayHasEntries = entries.some((e) => e.day === key);
return (
<th key={key} className="text-center text-sm font-semibold pb-3 px-2">
<span className="group inline-flex items-center gap-1">
{label}
{dayHasEntries && (
<button
onClick={() => setClearTarget({ type: "day", day: key, label })}
title={t("clearDay")}
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive transition-opacity"
>
<Trash2 className="h-3 w-3" />
</button>
)}
</span>
</th>
);
})}
</tr>
</thead>
<tbody>
@@ -541,6 +599,27 @@ export function MealPlanner({
)}
<BatchCookGenerateDialog open={showBatchModal} onOpenChange={setShowBatchModal} />
<AlertDialog open={clearTarget !== null} onOpenChange={(open) => !open && setClearTarget(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{clearTarget?.type === "day" ? t("clearDayConfirmTitle", { day: clearTarget.label }) : t("clearWeekConfirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>{t("clearConfirmDescription")}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={clearing}>{tCommon("cancel")}</AlertDialogCancel>
<AlertDialogAction
disabled={clearing}
onClick={() => { void confirmClear(); }}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{tCommon("delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
+8 -1
View File
@@ -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",
+7 -1
View File
@@ -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",
+7 -1
View File
@@ -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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@epicure/web",
"version": "0.16.0",
"version": "0.17.0",
"private": true,
"scripts": {
"dev": "next dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "epicure",
"version": "0.16.0",
"version": "0.17.0",
"private": true,
"scripts": {
"dev": "pnpm --filter web dev",