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:
@@ -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 });
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@epicure/web",
|
||||
"version": "0.16.0",
|
||||
"version": "0.17.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "epicure",
|
||||
"version": "0.16.0",
|
||||
"version": "0.17.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "pnpm --filter web dev",
|
||||
|
||||
Reference in New Issue
Block a user