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.
|
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
|
## 0.16.0 — 2026-07-13 21:59
|
||||||
|
|
||||||
### Added
|
### 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 { NextRequest, NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
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 { requireSession } from "@/lib/api-auth";
|
||||||
import { getMealPlanAccessById, canWriteMealPlan } from "@/lib/meal-plan-access";
|
import { getMealPlanAccessById, canWriteMealPlan } from "@/lib/meal-plan-access";
|
||||||
import { dispatchWebhook } from "@/lib/webhooks";
|
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 (!access) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
if (!canWriteMealPlan(access.role)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
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");
|
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(
|
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 });
|
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 { Plus, Trash2, Sparkles, ChefHat, Check } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
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 { FakeProgressBar } from "@/components/ui/fake-progress-bar";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -190,8 +200,11 @@ export function MealPlanner({
|
|||||||
const [pantryMode, setPantryMode] = useState(false);
|
const [pantryMode, setPantryMode] = useState(false);
|
||||||
const [aiDifficulty, setAiDifficulty] = useState<"" | "easy" | "medium" | "hard">("");
|
const [aiDifficulty, setAiDifficulty] = useState<"" | "easy" | "medium" | "hard">("");
|
||||||
const [targetNutritionGoals, setTargetNutritionGoals] = useState(false);
|
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 t = useTranslations("mealPlan");
|
||||||
const tRecipe = useTranslations("recipe");
|
const tRecipe = useTranslations("recipe");
|
||||||
|
const tCommon = useTranslations("common");
|
||||||
|
|
||||||
async function generateWithAi() {
|
async function generateWithAi() {
|
||||||
setAiGenerating(true);
|
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) {
|
async function markCooked(entry: Entry) {
|
||||||
if (!entry.recipe || markingCookedIds.has(entry.id)) return;
|
if (!entry.recipe || markingCookedIds.has(entry.id)) return;
|
||||||
setMarkingCookedIds((prev) => new Set(prev).add(entry.id));
|
setMarkingCookedIds((prev) => new Set(prev).add(entry.id));
|
||||||
@@ -325,6 +361,12 @@ export function MealPlanner({
|
|||||||
<>
|
<>
|
||||||
{/* AI Generate buttons */}
|
{/* AI Generate buttons */}
|
||||||
<div className="flex justify-end gap-2 mb-4">
|
<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">
|
<Button variant="outline" size="sm" onClick={() => setShowBatchModal(true)} className="gap-2">
|
||||||
<ChefHat className="h-4 w-4" />
|
<ChefHat className="h-4 w-4" />
|
||||||
{t("batchCooking")}
|
{t("batchCooking")}
|
||||||
@@ -346,9 +388,25 @@ export function MealPlanner({
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="text-left text-xs font-medium text-muted-foreground pb-3" />
|
<th className="text-left text-xs font-medium text-muted-foreground pb-3" />
|
||||||
{DAYS.map(({ key, label }) => (
|
{DAYS.map(({ key, label }) => {
|
||||||
<th key={key} className="text-center text-sm font-semibold pb-3 px-2">{label}</th>
|
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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -541,6 +599,27 @@ export function MealPlanner({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<BatchCookGenerateDialog open={showBatchModal} onOpenChange={setShowBatchModal} />
|
<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.
|
// 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 = {
|
export type ChangelogEntry = {
|
||||||
version: string;
|
version: string;
|
||||||
@@ -11,6 +11,13 @@ export type ChangelogEntry = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: 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",
|
version: "0.16.0",
|
||||||
date: "2026-07-13 21:59",
|
date: "2026-07-13 21:59",
|
||||||
|
|||||||
@@ -829,7 +829,13 @@
|
|||||||
"removeEntry": "Remove meal",
|
"removeEntry": "Remove meal",
|
||||||
"markCooked": "Mark as cooked",
|
"markCooked": "Mark as cooked",
|
||||||
"markCookedSuccess": "Marked 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": {
|
"pantry": {
|
||||||
"title": "Pantry",
|
"title": "Pantry",
|
||||||
|
|||||||
@@ -817,7 +817,13 @@
|
|||||||
"removeEntry": "Retirer le repas",
|
"removeEntry": "Retirer le repas",
|
||||||
"markCooked": "Marquer comme cuisiné",
|
"markCooked": "Marquer comme cuisiné",
|
||||||
"markCookedSuccess": "Marqué 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": {
|
"pantry": {
|
||||||
"title": "Garde-manger",
|
"title": "Garde-manger",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@epicure/web",
|
"name": "@epicure/web",
|
||||||
"version": "0.16.0",
|
"version": "0.17.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "epicure",
|
"name": "epicure",
|
||||||
"version": "0.16.0",
|
"version": "0.17.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm --filter web dev",
|
"dev": "pnpm --filter web dev",
|
||||||
|
|||||||
Reference in New Issue
Block a user