feat: pantry notes/categories, ingredient-alias matching, cook-log edit/delete, fork-list popover (v0.83.0)

Pantry: notes + category fields (collapsible grouping like the shopping list), a "Merge duplicates" cleanup action, and fixed quantity display precision (was showing raw decimal(10,4) strings like "0.3333 kg" everywhere — pantry, shopping list, print views, Markdown exports).

Ingredient-alias matching: the ingredients table (canonical name + aliases) existed but was never populated or used. Seeded ~10 bilingual EN/FR staples and wired resolution into pantry add/edit, can-cook scoring, auto-deduct-on-cook, and shopping-list pantry-awareness, so "sel"/"sel fin"/"table salt" are recognized as the same ingredient.

Cook log: entries from "Mark cooked" can now be edited and deleted (previously log-only, no fix-a-mistake path). The "Cooked N times" text is a hover tooltip listing every date and opens a full manage sheet on click.

Also: the "Forked by N others" backlink is now a click-to-open popover instead of an always-inline list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-24 15:13:33 +02:00
parent a488b544dc
commit 93936eae10
37 changed files with 7255 additions and 117 deletions
@@ -0,0 +1,103 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
export type CookLog = {
id: string;
cookedAt: string;
servings: number | null;
notes: string | null;
};
export function EditCookLogDialog({
recipeId,
log,
open,
onOpenChange,
onSaved,
}: {
recipeId: string;
log: CookLog;
open: boolean;
onOpenChange: (open: boolean) => void;
onSaved: (updated: CookLog) => void;
}) {
const t = useTranslations("recipe");
const tCommon = useTranslations("common");
const [date, setDate] = useState(log.cookedAt.slice(0, 10));
const [servings, setServings] = useState(log.servings ?? "");
const [notes, setNotes] = useState(log.notes ?? "");
const [saving, setSaving] = useState(false);
async function handleSave() {
setSaving(true);
try {
const res = await fetch(`/api/v1/recipes/${recipeId}/cooked/${log.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
cookedAt: date,
servings: servings === "" ? null : Number(servings),
notes: notes.trim() || null,
}),
});
if (!res.ok) { toast.error(t("editCookLogFailed")); return; }
toast.success(t("editCookLogSaved"));
onSaved({ id: log.id, cookedAt: new Date(date).toISOString(), servings: servings === "" ? null : Number(servings), notes: notes.trim() || null });
onOpenChange(false);
} finally {
setSaving(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{t("editCookLogTitle")}</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="cook-log-date">{t("markCookedDateLabel")}</Label>
<Input id="cook-log-date" type="date" value={date} max={new Date().toISOString().slice(0, 10)} onChange={(e) => setDate(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label htmlFor="cook-log-servings">{t("markCookedServingsLabel")}</Label>
<Input
id="cook-log-servings"
type="number"
min={1}
value={servings}
onChange={(e) => setServings(e.target.value === "" ? "" : Math.max(1, Number(e.target.value)))}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="cook-log-notes">{t("cookLogNotesLabel")}</Label>
<Textarea id="cook-log-notes" value={notes} onChange={(e) => setNotes(e.target.value)} rows={2} />
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
{tCommon("cancel")}
</Button>
<Button type="button" onClick={() => { void handleSave(); }} disabled={saving}>
{saving ? tCommon("saving") : tCommon("save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,38 @@
"use client";
import Link from "next/link";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
export function ForkedByPopover({
label,
forks,
}: {
label: string;
forks: { id: string; title: string }[];
}) {
return (
<Popover>
<PopoverTrigger className="text-sm text-muted-foreground hover:text-foreground underline-offset-2 hover:underline text-left">
{label}
</PopoverTrigger>
<PopoverContent className="w-64 p-2" align="start">
<ul className="space-y-0.5">
{forks.map((f) => (
<li key={f.id}>
<Link
href={`/recipes/${f.id}`}
className="block rounded px-2 py-1.5 text-sm hover:bg-accent truncate"
>
{f.title}
</Link>
</li>
))}
</ul>
</PopoverContent>
</Popover>
);
}
@@ -25,7 +25,7 @@ interface MarkCookedDialogProps {
baseServings: number;
batchDishId?: string;
trigger: React.ReactNode;
onLogged?: (cookedAt: string) => void;
onLogged?: (log: { id: string; cookedAt: string; servings: number }) => void;
}
/** Logs a cook event — date, servings, and whether to deduct matching
@@ -55,9 +55,10 @@ export function MarkCookedDialog({ recipeId, baseServings, batchDishId, trigger,
}),
});
if (!res.ok) throw new Error();
const { id } = await res.json() as { id: string };
toast.success(t("markCookedSuccess"));
setOpen(false);
onLogged?.(date);
onLogged?.({ id, cookedAt: date, servings });
} catch {
toast.error(t("markCookedFailed"));
} finally {
@@ -1,33 +1,72 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { ChefHat } from "lucide-react";
import { toast } from "sonner";
import { ChefHat, Pencil, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useLocale } from "@/lib/i18n/provider";
import { MarkCookedDialog } from "./mark-cooked-dialog";
import { EditCookLogDialog, type CookLog } from "./edit-cook-log-dialog";
const TOOLTIP_DATE_LIMIT = 8;
export function MarkCookedSection({
recipeId,
baseServings,
cookCount,
lastCookedAt,
initialLogs,
}: {
recipeId: string;
baseServings: number;
cookCount: number;
lastCookedAt: string | null;
initialLogs: CookLog[];
}) {
const t = useTranslations("recipe");
const router = useRouter();
const tCommon = useTranslations("common");
const { locale } = useLocale();
const [logs, setLogs] = useState<CookLog[]>(initialLogs);
const [sheetOpen, setSheetOpen] = useState(false);
const [editingLog, setEditingLog] = useState<CookLog | null>(null);
const [confirmId, setConfirmId] = useState<string | null>(null);
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString(locale, { month: "short", day: "numeric", year: "numeric" });
}
async function handleDelete(id: string) {
const res = await fetch(`/api/v1/recipes/${recipeId}/cooked/${id}`, { method: "DELETE" });
if (res.ok) {
setLogs((prev) => prev.filter((l) => l.id !== id));
toast.success(t("deleteCookLogSuccess"));
} else {
toast.error(t("deleteCookLogFailed"));
}
}
const cookCount = logs.length;
const lastCookedAt = logs[0]?.cookedAt ?? null;
const logPendingDelete = logs.find((l) => l.id === confirmId) ?? null;
return (
<div className="flex items-center gap-3">
<MarkCookedDialog
recipeId={recipeId}
baseServings={baseServings}
onLogged={() => router.refresh()}
onLogged={(log) => {
const entry = { id: log.id, cookedAt: new Date(log.cookedAt).toISOString(), servings: log.servings, notes: null };
setLogs((prev) => [...prev, entry].sort((a, b) => new Date(b.cookedAt).getTime() - new Date(a.cookedAt).getTime()));
}}
trigger={
<Button type="button" variant="outline" size="sm">
<ChefHat className="h-3.5 w-3.5" />
@@ -36,11 +75,95 @@ export function MarkCookedSection({
}
/>
{cookCount > 0 && (
<p className="text-xs text-muted-foreground">
{cookCount === 1 ? t("markCookedCountSingular") : t("markCookedCountPlural", { count: cookCount })}
{lastCookedAt && t("markCookedLast", { date: new Date(lastCookedAt).toLocaleDateString(locale, { month: "short", day: "numeric" }) })}
</p>
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={
<button
type="button"
onClick={() => setSheetOpen(true)}
className="text-xs text-muted-foreground hover:text-foreground underline-offset-2 hover:underline text-left"
>
{cookCount === 1 ? t("markCookedCountSingular") : t("markCookedCountPlural", { count: cookCount })}
{lastCookedAt && t("markCookedLast", { date: formatDate(lastCookedAt) })}
</button>
} />
<TooltipContent>
<ul className="space-y-0.5">
{logs.slice(0, TOOLTIP_DATE_LIMIT).map((l) => (
<li key={l.id}>{formatDate(l.cookedAt)}</li>
))}
</ul>
{logs.length > TOOLTIP_DATE_LIMIT && (
<p className="text-muted-foreground mt-1">{t("cookLogMore", { count: logs.length - TOOLTIP_DATE_LIMIT })}</p>
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
<SheetContent>
<SheetHeader>
<SheetTitle>{t("cookLogSheetTitle")}</SheetTitle>
</SheetHeader>
<div className="p-2 mt-6 space-y-2">
{logs.length === 0 && <p className="text-sm text-muted-foreground">{t("cookLogEmpty")}</p>}
{logs.map((log) => (
<div key={log.id} className="flex items-center justify-between gap-2 border rounded-lg p-3">
<div className="min-w-0">
<p className="text-sm font-medium">{formatDate(log.cookedAt)}</p>
{log.servings && <p className="text-xs text-muted-foreground">{t("markCookedServingsLabel")}: {log.servings}</p>}
{log.notes && <p className="text-xs text-muted-foreground italic">{log.notes}</p>}
</div>
<div className="flex items-center gap-1 shrink-0">
<button onClick={() => setEditingLog(log)} aria-label={tCommon("edit")} className="text-muted-foreground hover:text-foreground p-1.5">
<Pencil className="h-3.5 w-3.5" />
</button>
<button onClick={() => setConfirmId(log.id)} aria-label={tCommon("delete")} className="text-muted-foreground hover:text-destructive p-1.5">
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
))}
</div>
</SheetContent>
</Sheet>
{editingLog && (
<EditCookLogDialog
recipeId={recipeId}
log={editingLog}
open={!!editingLog}
onOpenChange={(open) => !open && setEditingLog(null)}
onSaved={(updated) => {
setLogs((prev) => [...prev.filter((l) => l.id !== updated.id), updated].sort((a, b) => new Date(b.cookedAt).getTime() - new Date(a.cookedAt).getTime()));
setEditingLog(null);
}}
/>
)}
<AlertDialog open={confirmId !== null} onOpenChange={(open) => !open && setConfirmId(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("deleteCookLogConfirmTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{logPendingDelete ? t("deleteCookLogConfirmDescription", { date: formatDate(logPendingDelete.cookedAt) }) : ""}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (confirmId) void handleDelete(confirmId);
setConfirmId(null);
}}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{tCommon("delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}