feat: mark recipe as cooked (multi-cook history + backdate) and enable auto-deduct pantry on cook (v0.75.0)

Adds a general-purpose "mark cooked" dialog for any recipe (not just batch-cook dishes), with a date picker for backdating and a pantry-deduct checkbox defaulted on. Also flips the previously dead-in-the-UI deductFromPantry flag to true for the existing batch-cook and meal-planner cook actions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-24 11:28:41 +02:00
parent 55c6fc5ab7
commit 04a911b431
14 changed files with 245 additions and 9 deletions
@@ -0,0 +1,119 @@
"use client";
import { cloneElement, isValidElement, useState, type ReactElement } from "react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { ChefHat } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
interface MarkCookedDialogProps {
recipeId: string;
baseServings: number;
batchDishId?: string;
trigger: React.ReactNode;
onLogged?: (cookedAt: string) => void;
}
/** Logs a cook event — date, servings, and whether to deduct matching
* ingredients from the pantry (default on). A recipe can be logged as
* cooked any number of times; each submission is a new row, never an
* update. */
export function MarkCookedDialog({ recipeId, baseServings, batchDishId, trigger, onLogged }: MarkCookedDialogProps) {
const t = useTranslations("recipe");
const tCommon = useTranslations("common");
const [open, setOpen] = useState(false);
const [date, setDate] = useState(todayIso());
const [servings, setServings] = useState(baseServings);
const [deductFromPantry, setDeductFromPantry] = useState(true);
const [submitting, setSubmitting] = useState(false);
async function handleSubmit() {
setSubmitting(true);
try {
const res = await fetch(`/api/v1/recipes/${recipeId}/cooked`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
servings,
cookedAt: date,
deductFromPantry,
...(batchDishId ? { batchDishId } : {}),
}),
});
if (!res.ok) throw new Error();
toast.success(t("markCookedSuccess"));
setOpen(false);
onLogged?.(date);
} catch {
toast.error(t("markCookedFailed"));
} finally {
setSubmitting(false);
}
}
const triggerElement = isValidElement(trigger)
? cloneElement(trigger as ReactElement<{ onClick?: () => void }>, { onClick: () => setOpen(true) })
: trigger;
return (
<>
{triggerElement}
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ChefHat className="h-5 w-5 text-primary" />
{t("markCookedTitle")}
</DialogTitle>
<DialogDescription>{t("markCookedDescription")}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="mark-cooked-date">{t("markCookedDateLabel")}</Label>
<Input id="mark-cooked-date" type="date" value={date} max={todayIso()} onChange={(e) => setDate(e.target.value || todayIso())} />
</div>
<div className="space-y-2">
<Label htmlFor="mark-cooked-servings">{t("markCookedServingsLabel")}</Label>
<Input
id="mark-cooked-servings"
type="number"
min={1}
value={servings}
onChange={(e) => setServings(Math.max(1, Number(e.target.value) || baseServings))}
/>
</div>
<div className="flex items-center justify-between gap-3 rounded-lg border p-3">
<div>
<Label htmlFor="mark-cooked-deduct">{t("markCookedDeductLabel")}</Label>
<p className="text-xs text-muted-foreground">{t("markCookedDeductDescription")}</p>
</div>
<Switch id="mark-cooked-deduct" checked={deductFromPantry} onCheckedChange={setDeductFromPantry} />
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setOpen(false)} disabled={submitting}>
{tCommon("cancel")}
</Button>
<Button type="button" onClick={() => { void handleSubmit(); }} disabled={submitting}>
{submitting ? t("markCookedSaving") : t("markCookedSubmit")}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}